NotesAyomide Olajide
System Design3 min read

Design boundaries before you design endpoints

A practical way to turn a vague product request into boundaries, ownership, and APIs that can evolve without accidental coupling.

Explicit boundaries, clear ownershipThe interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.01Interface02Application03Domain service04Owned database05Domain event06Async worker
The interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.
Explicit boundaries, clear ownershipThe interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.01Interface02Application03Domain service04Owned database05Domain event06Async worker
The interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.

Start with the change that must remain possible

An API can look tidy on day one and still encode a painful future. The warning sign is an endpoint that knows too much: it validates a payment, mutates an account, publishes an event, and formats a response for three different clients. The problem is not that the endpoint is large. The problem is that its boundaries are undefined.

Before naming routes, write down the decisions the system must make and the data each decision owns. This is an illustrative scenario, not a claim about a particular production system. A subscription product might separate identity, billing, entitlements, and notifications even if the first release is deployed as one service.

A boundary is an ownership contract

A useful boundary answers three questions: which invariants does this component protect, which data may it change, and what does it publish for other components? If billing owns invoice state, an account service should not update invoice status just because both records live in one database.

This does not require microservices. A modular monolith can enforce the same ownership with modules, explicit commands, and private persistence adapters. Start with a dependency direction that is easy to test, then extract a process only when deployment, scale, or team ownership makes that separation valuable.

Explicit boundaries, clear ownershipThe interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.01Interface02Application03Domain service04Owned database05Domain event06Async worker
The interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.
Explicit boundaries, clear ownershipThe interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.01Interface02Application03Domain service04Owned database05Domain event06Async worker
The interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.
type CreateInvoice = { customerId: string; lines: Array<{ sku: string; quantity: number }> }

// The boundary owns validation and invoice invariants.
export async function createInvoice(input: CreateInvoice) {
  const order = await orders.getForBilling(input.customerId, input.lines)
  const invoice = Invoice.create(order)
  await db.transaction(async (tx) => {
    await tx.invoices.save(invoice)
    await tx.outbox.append({ type: 'invoice.created', invoiceId: invoice.id })
  })
  // A worker delivers committed outbox events; consumers handle duplicates.
  return invoice
}

Choose interfaces that reveal trade-offs

Commands are often a better internal interface than shared mutable models because they reveal intent. Events are useful when a consumer can be eventually consistent and should not block the original decision. A direct call is more appropriate when the caller needs a synchronous answer or a failure must abort the operation.

The common mistake is drawing a beautiful box diagram and then sharing one ORM model across every box. That creates the appearance of boundaries without the cost of maintaining them. Test the boundary with contract tests and a rule that imports may only point in one direction.

Explicit boundaries, clear ownershipThe interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.01Interface02Application03Domain service04Owned database05Domain event06Async worker
The interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.
Explicit boundaries, clear ownershipThe interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.01Interface02Application03Domain service04Owned database05Domain event06Async worker
The interface calls the application layer. Domain services own their data; asynchronous work crosses an explicit event boundary.

Lessons to carry forward

Design around decisions and invariants, not nouns in a database. Keep the first deployment simple when it can be, but make ownership explicit. The best boundary is the one that makes change safer, failure more visible, and the next engineer less dependent on accidental knowledge.