Architecture patterns and when to use them
Layered, hexagonal, vertical slices, monolith, microservices, serverless: which pattern fits which problem, and what does each one actually cost?
Architecture patterns answer two separate questions: how code inside a deployable is organised (layered, hexagonal, vertical slices, MVC) and how deployables are cut (monolith, modular monolith, microservices, functions). Pick the code structure for change locality and testability; pick the deployment topology for team autonomy and operational needs, and pay for distribution only when evidence demands it.
THE MENTAL MODEL
Treat structure and topology as two independent dials. The code dial decides which direction dependencies point and what changes together: layers group by technical role, hexagonal puts the domain in the centre with every technology behind a port, vertical slices group by request so one feature change touches one folder. The deployment dial decides where the network sits: every boundary you turn into a process boundary converts a function call into latency, partial failure, eventual consistency and a deploy pipeline. Forces pick the setting: Conway's law (the system will mirror your team communication), change coupling (what must ship together), data ownership (who may write which tables), operational maturity (can you trace, deploy and page across N services) and latency budgets (how many hops a request can afford). Most teams should run a well-bounded modular monolith with a hexagonal or slice-based core and extract services only where a boundary is stable and independent operation has measurable value.
HOW IT FITS TOGETHER
| Unit of deploy | Coupling | Ops cost | Team fit | Main risk | |
|---|---|---|---|---|---|
| Layered (n-tier) | Any; a code structure | Low between layers, high across a feature (touches every layer) | None added | Small team, CRUD-heavy domain | Logic leaks into controllers or SQL; one change edits every layer |
| Hexagonal / clean / onion | Any; a code structure | Domain depends on nothing; adapters depend on ports | None added; more files and mapping | Rich domain, several inputs or outputs | Ceremony: interfaces with one implementation and anaemic pass-throughs |
| Vertical slices | Any; a code structure | Low between features, high within one | None added | Many independent features, mature refactorers | Duplicated rules across slices when nobody extracts the domain |
| MVC (web) | Any; a UI-level structure | View and controller coupled to the framework | None added | Server-rendered apps, framework conventions | Fat controllers or models become the whole architecture |
| Monolith | One process, one pipeline | Whatever the code allows; easy to tangle | Lowest: one deploy, one DB, local calls | One to a few teams | Big ball of mud when module boundaries are not enforced |
| Modular monolith | One process, modules with public APIs | Enforced by build rules; one shared transaction | Low | Several teams owning modules in one repo | Boundaries erode unless checked in CI |
| Microservices | Service per capability, own DB | Low design-time if APIs are stable; runtime via network | High: tracing, contracts, pipelines, on-call per service | Many autonomous teams with DevOps maturity | Distributed monolith: lockstep deploys and chatty sync calls |
| Serverless functions | Function per route or event | Low per function; config and events couple them | Low infra, high observability and limits work | Spiky or event-driven loads, small teams | Cold starts, connection storms, logic scattered across triggers |
- Driving adaptersHTTP controllers, CLI, queue consumers, test harnesses. Translate a request into a use-case call.
- Inbound ports (use cases)The application API: CancelOrder, PlaceOrder. Orchestrate the domain, own the transaction boundary.
- Domain modelEntities, value objects and invariants. No framework, ORM, HTTP or clock imports.
- Outbound portsInterfaces the core declares for what it needs: OrderRepository, PaymentGateway, Clock.
- Driven adaptersPostgres, Stripe, Kafka, S3 implementations, plus in-memory fakes for tests.
- Composition rootmain, the DI container or the function entry point. The only place that knows every concrete adapter.
- Modules by business capabilityTop-level folders are orders, billing, catalog, each internally layered or hexagonal.
- Public API per moduleOther modules import only the module's index or API package; tables are private by convention.
- Enforce boundaries in CIdependency-cruiser, ArchUnit or Spring Modulith fail the build on illegal imports and cycles.
- Measure pressureChange coupling, release contention, scaling or isolation needs, team ownership conflicts.
- Extract a stable moduleGive it its own datastore and pipeline; replace in-process calls with an API or events.
- Operate itTracing, contract tests, SLOs and on-call. If you cannot, the extraction is premature.
KEY TERMS
- Structure vs topology
- Layered, hexagonal, slices and MVC organise code; monolith, microservices and functions decide process and network boundaries. They are orthogonal: a hexagonal monolith and a layered microservice are both normal. Most arguments that conflate them end with a network boundary where a package boundary would do.
- Ports, adapters and the dependency rule
- Cockburn's ports are purposeful conversations the application owns; adapters convert a technology to that conversation. Driving (primary) adapters call in, driven (secondary) adapters are called out to. Clean and onion architecture restate the same rule: source dependencies point inward and the inner circle names nothing in the outer one.
- Conway's law
- A system's design copies its organisation's communication structure. Service boundaries that cut across team boundaries create constant cross-team coordination; the inverse Conway maneuver reshapes teams around the capabilities you want as boundaries. Architecture and team design have to move together.
- Change coupling
- What must change, test and deploy together. Layers couple a feature across folders; slices and capability modules keep it local. Across services, change coupling shows up as lockstep deploys, which means you are paying for distribution without getting independence.
- Data ownership
- One module or service writes a given table; others ask through its API or consume its events. Shared tables across services couple schemas and deploys more tightly than shared code. In a modular monolith, enforce it by convention or separate schemas; in microservices, by separate databases.
- Operational maturity
- Fowler's microservice premium: automated deployment, monitoring, distributed tracing, failure handling and eventual consistency are prerequisites, not follow-ups. A team that cannot yet trace a request across three services should not create thirty.
- Latency budgets
- Each synchronous hop adds network latency, serialisation and a new failure mode, and tail latencies compound across a call chain. If a page needs data from five services synchronously, the boundaries are probably in the wrong place, or you need event-carried copies of the data.
IN YOUR STACK
TypeScript · Plain TS + any HTTP framework Server
// domain/order.ts: pure rules, imports nothing from infrastructure
export class Order {
constructor(readonly id: string, public status: "open" | "cancelled", readonly paidAt?: Date) {}
cancel(now: Date) {
if (this.paidAt && now.getTime() - this.paidAt.getTime() > 86_400_000) throw new Error("too late to cancel");
this.status = "cancelled";
}
}
// application/ports.ts: outbound port, declared and owned by the core
export interface OrderRepository { get(id: string): Promise<Order | undefined>; save(o: Order): Promise<void> }
// application/cancel-order.ts: inbound port as a use case
export const cancelOrder = (orders: OrderRepository, now: () => Date) => async (id: string) => {
const order = await orders.get(id);
if (!order) throw new NotFound(id);
order.cancel(now());
await orders.save(order);
};
// adapters/in-memory-orders.ts: test adapter; PostgresOrders implements the same port
export class InMemoryOrders implements OrderRepository {
private rows = new Map<string, Order>();
async get(id: string) { return this.rows.get(id); }
async save(o: Order) { this.rows.set(o.id, o); }
}
const cancel = cancelOrder(new PostgresOrders(pool), () => new Date()); // main.ts: composition root
app.post("/orders/:id/cancel", async (req, res) => { await cancel(req.params.id); res.sendStatus(204); });- The clock is a port too: injecting it makes the 24-hour rule testable without fakes of global time.
- Unit-test cancelOrder with InMemoryOrders; run the same contract tests against PostgresOrders to keep the adapters honest.
- Do not add a port for something with one implementation that will never be swapped or faked; AWS's hexagonal guidance calls that maintenance overhead.
Go · Standard library Server
package billing // the consumer declares the ports it needs, nothing more
type Invoices interface {
Overdue(ctx context.Context, asOf time.Time) ([]Invoice, error)
}
type Notifier interface {
Remind(ctx context.Context, customerID, invoiceID string) error
}
type Reminders struct {
invoices Invoices
notify Notifier
}
func NewReminders(i Invoices, n Notifier) *Reminders { return &Reminders{invoices: i, notify: n} }
func (r *Reminders) Run(ctx context.Context, now time.Time) error {
overdue, err := r.invoices.Overdue(ctx, now)
if err != nil {
return fmt.Errorf("load overdue: %w", err)
}
for _, inv := range overdue {
if err := r.notify.Remind(ctx, inv.CustomerID, inv.ID); err != nil {
return fmt.Errorf("remind %s: %w", inv.ID, err)
}
}
return nil
}
// package postgres: NewStore(db) returns a concrete *Store that satisfies billing.Invoices implicitly- Go's guidance is that interfaces belong in the package that uses them, and implementations return concrete types. That is exactly a port owned by the core.
- Implicit satisfaction means the adapter package has no compile-time dependency on the domain, so the dependency rule holds without extra wiring.
- Keep interfaces small and only as wide as the consumer needs; a one-method port is idiomatic.
Java · Spring Boot Server
// order/domain/OrderRepository.java: the port, owned by the domain
public interface OrderRepository { Optional<Order> findById(OrderId id); void save(Order order); }
// order/application/CancelOrder.java: use case, depends only on ports
@Service
public class CancelOrder {
private final OrderRepository orders;
private final Clock clock;
public CancelOrder(OrderRepository orders, Clock clock) { this.orders = orders; this.clock = clock; }
@Transactional
public void handle(OrderId id) {
Order order = orders.findById(id).orElseThrow(() -> new OrderNotFound(id));
order.cancel(clock.instant());
orders.save(order);
}
}
// order/adapter/out/JdbcOrderRepository.java: driven adapter, package-private
@Repository
class JdbcOrderRepository implements OrderRepository {
private final JdbcClient jdbc;
JdbcOrderRepository(JdbcClient jdbc) { this.jdbc = jdbc; }
public Optional<Order> findById(OrderId id) { /* SELECT ... -> map to Order */ }
public void save(Order order) { /* UPDATE orders SET status = ? WHERE id = ? */ }
}- Constructor injection without @Autowired: Spring uses a single constructor automatically, and the Spring team recommends constructors for mandatory dependencies.
- @Transactional on the use case is a pragmatic concession; purists move it into an adapter or decorator. Keep JPA entities out of the domain package either way.
- Declare a Clock bean so the use case never calls Instant.now() directly.
TypeScript · Kysely + zod Server
// src/features/
// cancel-order/ endpoint.ts handler.ts handler.test.ts
// list-orders/ endpoint.ts query.ts
// src/features/cancel-order/handler.ts
import { z } from "zod";
import { db } from "../../shared/db"; // shared infrastructure is fine; shared feature logic is not
export const CancelOrder = z.object({ orderId: z.uuid(), reason: z.string().max(200) });
export async function cancelOrder(input: z.infer<typeof CancelOrder>, userId: string) {
return db.transaction().execute(async (trx) => {
const order = await trx.selectFrom("orders").selectAll()
.where("id", "=", input.orderId).where("user_id", "=", userId)
.forUpdate().executeTakeFirst();
if (!order) return { status: 404 } as const;
if (order.status !== "open") return { status: 409, error: "not cancellable" } as const;
await trx.updateTable("orders")
.set({ status: "cancelled", cancel_reason: input.reason })
.where("id", "=", order.id).execute();
return { status: 204 } as const;
});
}- One request, one folder: validation, data access and the response shape change together, so they live together. Deleting the feature deletes the folder.
- Slices start as transaction scripts. When two slices duplicate a rule, extract a domain object; that refactoring discipline is the price of the pattern.
- Read slices can query exactly the columns they need, which avoids the over-fetching a shared repository layer tends to create.
JavaScript · dependency-cruiser Server
// .dependency-cruiser.cjs (CI: npx depcruise src --config .dependency-cruiser.cjs)
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "modules-only-via-public-api",
comment: "Import another module through its index.ts, never its internals",
severity: "error",
from: { path: "^src/modules/([^/]+)/" },
to: { path: "^src/modules/[^/]+/", pathNot: ["^src/modules/$1/", "^src/modules/[^/]+/index\\.ts$"] },
},
{
name: "domain-is-framework-free",
severity: "error",
from: { path: "^src/modules/[^/]+/domain/" },
to: { path: ["^node_modules/(express|pg|@aws-sdk)/", "^src/modules/[^/]+/adapters/"] },
},
{ name: "no-circular", severity: "error", from: {}, to: { circular: true } },
],
options: { tsConfig: { fileName: "tsconfig.json" }, doNotFollow: { path: "node_modules" } },
};- $1 in to.pathNot refers to the group captured in from.path, so a module may import its own internals but only other modules' index files.
- Paths are regular expressions, not globs. Arrays of patterns are allowed and easier to read.
- Run it in CI from day one; boundaries that are only documented erode within weeks.
Java · Spring Modulith Server
// com/acme/shop/inventory/package-info.java
@org.springframework.modulith.ApplicationModule(
allowedDependencies = { "order :: events" }
)
package com.acme.shop.inventory;
// com/acme/shop/order/events/package-info.java: the only part of order others may use
@org.springframework.modulith.NamedInterface("events")
package com.acme.shop.order.events;
// src/test/java/com/acme/shop/ModularityTests.java
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
@Test
void verifiesModuleStructure() {
modules.verify(); // no cycles, no access to internal packages, only allowed dependencies
}
@Test
void writesDiagrams() {
new Documenter(modules).writeModulesAsPlantUml().writeIndividualModulesAsPlantUml();
}
}- Each direct sub-package of the @SpringBootApplication package is a module; its sub-packages are internal unless exposed as a named interface.
- allowedDependencies is optional; without it verify() still rejects cycles and references into other modules' internals.
- ArchUnit's layeredArchitecture() and onionArchitecture() rules give the same kind of check for code-structure layers inside a module.
TypeScript · AWS Lambda Server
// functions/reserve-slot.ts: a driving adapter; the core never sees API Gateway types
import type { APIGatewayProxyHandlerV2WithJWTAuthorizer } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { reserveSlot, SlotTaken } from "../core/reserve-slot"; // pure use case
import { DynamoSlots } from "../adapters/dynamo-slots"; // implements the Slots port
// Created once per execution environment and reused by later invocations.
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const slots = new DynamoSlots(ddb, process.env.TABLE_NAME!);
export const handler: APIGatewayProxyHandlerV2WithJWTAuthorizer = async (event) => {
const body = JSON.parse(event.body ?? "{}");
if (typeof body.slotId !== "string") return { statusCode: 400, body: "slotId required" };
const userId = String(event.requestContext.authorizer.jwt.claims.sub);
try {
const booking = await reserveSlot(slots, { slotId: body.slotId, userId });
return { statusCode: 201, body: JSON.stringify(booking) };
} catch (err) {
if (err instanceof SlotTaken) return { statusCode: 409, body: err.message };
throw err; // let Lambda record the failure
}
};- Initialise SDK clients and connections outside the handler, as AWS recommends, and keep user data out of module scope.
- The same reserveSlot core can run behind an SQS trigger or in a container later; only the adapter changes. That is the escape hatch from lock-in.
- Event-source triggers deliver at least once, so the use case must be idempotent.
WHEN TO REACH FOR WHAT
Layered (n-tier)
Use when The domain is mostly CRUD with light rules, the team is small, and framework conventions already give you controller, service and repository folders.
Avoid when It is the top-level split of a large codebase. Split by domain module first and layer inside each module; layered teams (front, back, DB) add friction to every feature.
Hexagonal / clean / onion
Use when Business rules are rich and long-lived, the same logic is driven by several entry points (HTTP, queues, jobs), or infrastructure will change.
Avoid when The service is thin glue over a database or API. Every port then has one adapter and the mapping code outweighs the logic.
Vertical slices
Use when The app is many mostly independent requests, features are added and removed often, and the team will refactor duplicated logic into domain objects when it appears.
Avoid when One dense domain model with invariants spanning many features, or a team that will copy-paste rules across slices without extracting them.
MVC (server-rendered web)
Use when A conventional web app built on Rails, Django, Laravel, Spring MVC or similar where the framework's MVC is the fastest path.
Avoid when You treat MVC as the whole architecture. It organises the UI edge; business rules still need a home outside controllers and ORM models.
Monolith
Use when A new product, unclear boundaries, one or two teams. One deploy, one database, ACID transactions and local calls are the cheapest place to discover the domain.
Avoid when Never as an excuse for no internal structure. Without module boundaries it becomes a big ball of mud that nobody can split later.
Modular monolith
Use when Several teams, a domain whose boundaries are becoming clear, and you want ownership and enforced boundaries without the distributed-systems tax. The default for most teams.
Avoid when Modules genuinely need different scaling, runtimes, release cadences or isolation (compliance, blast radius) and you can operate separate services.
Microservices
Use when Many autonomous teams, stable capability boundaries, real needs for independent deploys or scaling, and mature CI/CD, tracing and on-call.
Avoid when Boundaries are still moving, one team owns everything, services would share a database, or a request would fan out to many synchronous calls.
Serverless functions
Use when Spiky or event-driven workloads, glue around managed services, scheduled jobs, or small teams who want no servers to patch.
Avoid when Steady high throughput, long-running or latency-critical work, heavy connection-based databases without a proxy, or when hundreds of functions would scatter one domain.
WHERE IT BITES
- Distributed monolith: services that share a database or must deploy in lockstep. You pay network, consistency and ops costs and get none of the independence.
- Splitting by technical layer across teams or services (a UI service, a logic service, a data service). Every feature then crosses every boundary; split by business capability instead.
- Hexagonal theatre: an interface, an implementation and a DTO mapper for every class, with no second adapter and no tests using fakes. Add a port where you need a seam, not by reflex.
- Boundaries enforced only in diagrams. Without a dependency-cruiser, ArchUnit or Spring Modulith check in CI, imports across module internals creep in during the first deadline.
- Synchronous call chains between services with no latency budget. Tail latencies and failure probabilities multiply; use event-carried state or co-locate the data instead.
- One Lambda per endpoint, each opening its own database connections. Bursts exhaust the database; use a proxy or pool and keep a shared core library.
CLOSE THE AI. EXPLAIN THIS.
Your team of eight is building a new B2B product and someone proposes twelve microservices. Name the forces you would check (team structure, change coupling, data ownership, operational maturity, latency budget), the topology you would start with, the code structure inside it, and the signal that would justify extracting the first service.WHEN IT BREAKS IN PRODUCTION
SOURCES
- Alistair Cockburn: Hexagonal architecture (opens in new tab)Checked
- Martin Fowler: Presentation Domain Data Layering (opens in new tab)Checked
- Jimmy Bogard: Vertical Slice Architecture (opens in new tab)Checked
- Martin Fowler: MonolithFirst (opens in new tab)Checked
- Martin Fowler: Conway's Law (opens in new tab)Checked
- Azure Architecture Center: Architecture styles (opens in new tab)Checked
Explainer reviewed