Vertical Slice Architecture & the Modular Monolith

Organise code by feature instead of by technical layer, and split a monolith into well-bounded modules that can later become services. Folder structures, rules, and how to enforce boundaries.

Intermediate⏱ 6 min readLesson 12 of 13#architecture#vertical-slice#modular-monolith#modules#cqrs

Part 1: Vertical Slice Architecture

The big idea

A supermarket can be organised by type of container: all cans in aisle 1, all bottles in aisle 2, all boxes in aisle 3. Or by what you're shopping for: breakfast aisle, baking aisle, pet aisle. When you want to make pancakes, the second layout is far easier.

Layered architecture organises code by technical role (controllers here, services there, repositories over there). Vertical Slice Architecture (popularised by Jimmy Bogard) organises code by feature: everything needed for "Place order" lives together.

Horizontal layers vs vertical slicesHorizontal layers vs vertical slices

Layers vs slices

❌ Organised by layer                     βœ… Organised by feature (slices)
src/                                     src/features/
β”œβ”€β”€ controllers/                         β”œβ”€β”€ orders/
β”‚   β”œβ”€β”€ OrderController.ts               β”‚   β”œβ”€β”€ place-order/
β”‚   └── ProductController.ts             β”‚   β”‚   β”œβ”€β”€ endpoint.ts
β”œβ”€β”€ services/                            β”‚   β”‚   β”œβ”€β”€ handler.ts
β”‚   β”œβ”€β”€ OrderService.ts                  β”‚   β”‚   β”œβ”€β”€ validation.ts
β”‚   └── ProductService.ts                β”‚   β”‚   └── handler.test.ts
β”œβ”€β”€ repositories/                        β”‚   β”œβ”€β”€ cancel-order/
β”‚   β”œβ”€β”€ OrderRepository.ts               β”‚   └── get-order-history/
β”‚   └── ProductRepository.ts             └── products/
└── dtos/ …                                  β”œβ”€β”€ search-products/
                                             └── update-price/

"Add a gift-message field to orders" now touches one folder instead of five.

A slice = one request, top to bottom

Each slice handles one use case (a command or a query) from the endpoint all the way to the database, and chooses the simplest implementation that fits that feature.

// features/orders/get-order-history/handler.ts β€” a QUERY slice: simple, direct SQL
export async function getOrderHistory(db: Pool, customerId: string) {
  const { rows } = await db.query(
    `SELECT id, status, total_cents, created_at FROM orders
     WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 50`,
    [customerId],
  );
  return rows;
}

// features/orders/place-order/handler.ts β€” a COMMAND slice: uses a rich domain model
export async function placeOrder(deps: { orders: OrderRepository; events: EventBus }, cmd: PlaceOrderCommand) {
  const order = Order.create(cmd.customerId, cmd.lines);   // business rules in the domain object
  await deps.orders.save(order);
  await deps.events.publish(order.pullEvents());
  return { orderId: order.id };
}
Drawing diagram…

This fits naturally with CQRS: queries read directly, commands go through the domain.

Rules of thumb

RuleWhy
Maximise coupling inside a slice, minimise it between slicesA change stays in one place
Slices don't call each otherShare through the domain model, or events, instead
Share deliberatelyPut truly common code (domain entities, auth, logging) in a shared/ or domain/ folder, only once it's needed in several slices
Each slice picks its own complexityA simple read needn't pass through six layers

Trade-offs

βœ… Features are easy to find, change and delete; there's less ceremony for simple use cases; parallel work causes fewer conflicts. ❌ Risk of duplicated logic between slices (refactor shared rules into the domain once they repeat); needs discipline so the domain model doesn't get skipped where it matters.

πŸ’‘ Slices and Clean/Hexagonal aren't enemies. A popular combination: vertical slices for the application layer + a shared domain model in the centre + adapters at the edge.


Part 2: The Modular Monolith

The big idea

An apartment building is one structure with one foundation and one address, but inside it's divided into separate apartments with their own locked doors. Neighbours can't walk into each other's kitchens; they knock (a public interface).

A modular monolith is one deployable application divided into strongly separated modules, each owning its own code and data, talking only through explicit public interfaces.

Drawing diagram…

The rules that make it "modular"

  1. Modules follow business boundaries (DDD bounded contexts), not technical layers.
  2. Each module has a public API (a facade or interface); everything else is internal.
  3. No reaching into another module's tables. Each module owns its schema.
  4. Modules communicate through public APIs or in-process events.
  5. Enforce boundaries automatically, or they will erode.
src/modules/
β”œβ”€β”€ ordering/
β”‚   β”œβ”€β”€ index.ts          # βœ… public API: the only file others may import
β”‚   β”œβ”€β”€ domain/ …         # πŸ”’ internal
β”‚   β”œβ”€β”€ application/ …    # πŸ”’ internal
β”‚   └── infrastructure/   # πŸ”’ internal (uses the "ordering" DB schema)
β”œβ”€β”€ catalog/
β”‚   └── index.ts          # export { getProductSnapshot } from "./application/…"
└── billing/
    └── index.ts
// modules/catalog/index.ts β€” the catalog's public API
export type ProductSnapshot = { id: string; name: string; priceCents: number };
export { getProductSnapshot } from "./application/getProductSnapshot";

// modules/ordering/application/placeOrder.ts
import { getProductSnapshot } from "@/modules/catalog";                     // βœ… public API
// import { ProductEntity } from "@/modules/catalog/infrastructure/orm";    // ❌ forbidden

Enforcing boundaries

// .dependency-cruiser.js β€” fail the build if a module imports another module's internals
module.exports = {
  forbidden: [
    {
      name: "no-reaching-into-other-modules",
      from: { path: "^src/modules/([^/]+)/" },
      to: { path: "^src/modules/([^/]+)/(?!index\\.ts$)", pathNot: "^src/modules/$1/" },
    },
  ],
};

Other options: eslint-plugin-boundaries, Nx module boundaries, separate packages in a monorepo, ArchUnit (Java) or NetArchTest (.NET).

Why it's a great default

Traditional monolithModular monolithMicroservices
Deployables11Many
Internal boundariesWeak, often a "big ball of mud"βœ… Strong and enforcedβœ… Strong (the network)
Calls between partsIn-processβœ… In-process (fast, reliable)Network (slow, can fail)
Transactionsβœ… Easyβœ… Easy within a module❌ Sagas
Operational complexityβœ… Lowβœ… Low❌ High
Independent deploy and scaleβŒβŒβœ…
Path to microservicesPainfulβœ… Extract a module when needed–
Drawing diagram…

Shopify, GitHub and Basecamp run very large systems as (modular) monoliths. Many teams that jumped straight to microservices have moved back. A modular monolith keeps the door open: when a module truly needs independent scaling or deployment, its boundary already exists, so extraction is mostly moving code and swapping an in-process call for a network call.

Key takeaways

  • Vertical slices organise code by feature: one folder per use case, top to bottom; each slice picks its own level of complexity.
  • Slices pair well with CQRS and with a shared domain model in the centre.
  • A modular monolith is one deployable with strict, business-aligned modules that own their code and data.
  • Modules talk only through public APIs or events; enforce the boundaries with tooling.
  • It's the recommended starting point for most systems, with an easy path to extracting services later.