May 14, 2026

Where to draw service boundaries before you need to scale

Every growing team hits the moment where the monolith feels heavy. Deploys get scary. One team’s change breaks another team’s feature. Someone draws boxes on a whiteboard and says “microservices.”

I have lived through both sides: keeping a monolith longer than conference talks recommended, and splitting too early because a blog post made it sound inevitable. The useful question is never “monolith or microservices.” It is: where are the natural seams in your domain, and is the cost of crossing them worth paying now or later?

This is a long, practical guide: how I decide boundaries, patterns that actually work under failure, multi-tenant isolation, caching, and the signs you split too early or too late. Examples come from commerce platforms and volunteer-management SaaS I have worked on.


Part 1: Boundaries follow reasons to change

Conway’s law is real, and systems tend to mirror communication structure. But the first cut should be what changes together, not who sits together.

On a multi-tenant commerce platform I built (storefront, cart, POS, fulfillment):

Changed together constantly:

  • Product catalog, variants, inventory counts
  • Cart, checkout, payment state
  • Order fulfillment and courier assignment

Changed on different cadences:

  • Storefront CMS and theme (marketing-driven, weekly)
  • Embeddable widget runtime (browser compatibility, semver)
  • Accounting ledger (finance rules, audit, quarterly)

We kept catalog + cart + orders in one deployable for a long time. Not because we could not split them, but because nearly every feature touched two at once. Splitting would have meant distributed transactions or sagas for work still evolving every sprint.

The storefront CMS extracted earlier. Different scaling (read-heavy, cacheable), different deploy risk (content editors vs payment code), almost no synchronous writes into the order database.

Rule I use: if two modules share a database transaction boundary more than twice a quarter, think very hard before putting a network call between them.


Part 2: Start with modules inside one repo

Before separate machines, enforce boundaries in code:

src/
  catalog/
    domain/       # types, invariants
    repo/         # SQL / ORM
    service/      # public API for other modules
  orders/
    domain/
    repo/
    service/
  notifications/
    domain/
    repo/
    service/
  shared/
    db/
    events/
    config/

Rules:

  • Other modules import only from orders/service, never orders/repo
  • Cross-module calls go through typed functions, not shared global state
  • Each module owns its tables

This costs nothing at runtime and everything to refactor freely. When a boundary is stable for months and teams block each other in CI, consider extraction.


Part 3: Sync vs async boundaries

The cleanest seam is asynchronous: one side publishes an event, the other reacts when it can.

The problem with synchronous chains

Order paid. What needs to happen?

  • Reserve inventory
  • Notify warehouse / print supplier
  • Send receipt email
  • Update analytics
  • Sync to accounting ledger

Naive in-process version:

await orders.markPaid(orderId, paymentId);
await inventory.reserve(orderId);
await warehouse.enqueue(orderId);
await email.sendReceipt(orderId);
await analytics.track("order_paid", { orderId });
await ledger.recordSale(orderId);

This works in demos. In production:

  • warehouse.enqueue times out → user sees payment success, fulfillment never starts
  • email.sendReceipt throws → retry doubles inventory reservation unless guarded
  • One slow downstream service makes checkout latency unpredictable

Queue + outbox pattern

Write state and event in one database transaction:

await db.transaction(async (tx) => {
  await tx
    .update(orders)
    .set({ status: "paid", paidAt: new Date(), paymentId })
    .where(eq(orders.id, orderId));

  await tx.insert(outbox).values({
    id: crypto.randomUUID(),
    type: "order.paid",
    payload: { orderId, paymentId, tenantId },
    createdAt: new Date(),
  });
});

Separate worker polls outbox (or use logical decoding / CDC), publishes to queue, marks row processed.

Consumers must be idempotent:

async function onOrderPaid(event: OrderPaidEvent) {
  if (await processedStore.has(event.id)) return;

  await inventory.reserveOnce(event.orderId);
  await warehouse.enqueueOnce(event.orderId);
  await email.sendReceiptOnce(event.orderId);

  await processedStore.mark(event.id);
}

async function reserveOnce(orderId: string) {
  const existing = await db.query.reservations.findFirst({
    where: eq(reservations.orderId, orderId),
  });
  if (existing) return;

  await inventoryService.reserve(orderId);
}

Idempotency is not optional once work can retry. Network guarantees are “at least once,” not “exactly once.”


Part 4: Idempotency at HTTP boundaries

Clients retry. Webhooks duplicate. Mobile apps double-tap.

app.post("/payments/confirm", async (req, res) => {
  const key = req.headers["idempotency-key"];
  if (!key || key.length > 128) {
    return res.status(400).json({ error: "invalid_idempotency_key" });
  }

  const cached = await idempotencyStore.get(key);
  if (cached) {
    return res.status(cached.status).json(cached.body);
  }

  const result = await confirmPayment(req.body);

  await idempotencyStore.set(key, {
    status: result.ok ? 200 : 402,
    body: result,
    expiresAt: addHours(new Date(), 24),
  });

  return res.json(result);
});

Store key + response for 24 hours. Same key → same response, even if the first request actually completed but the client timed out.

Stripe popularized this. Your internal APIs deserve the same discipline.


Part 5: Multi-tenant isolation, the boundary you cannot get wrong

On SaaS, the worst bug is cross-tenant data leakage. Implicit tenancy, the “we always remember to filter” kind, fails eventually.

Application-level scoping

function shiftsRepo(db: Db, tenantId: string) {
  const scope = eq(shifts.organizationId, tenantId);

  return {
    findById(id: string) {
      return db.query.shifts.findFirst({
        where: and(scope, eq(shifts.id, id)),
      });
    },
  };
}

Pass tenantId from authenticated session into repo factory. Never trust client-supplied org ID without authorization check.

Database-level enforcement (Postgres RLS)

ALTER TABLE shifts ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON shifts
  USING (organization_id = current_setting('app.tenant_id', true)::uuid);

At request start:

await db.execute(sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`);

Even if application code forgets a filter, the database refuses wrong rows. Defense in depth.

Tenant-aware caching

Never cache keyed only by resource ID:

// dangerous
cache.get(`shift:${shiftId}`);

// safer
cache.get(`org:${tenantId}:shift:${shiftId}`);

Invalidate per tenant. A global cache key space is a leakage incident waiting to happen.


Part 6: When we actually split services

Notification service (success)

Extracted email, SMS, push. Why it worked:

  • Different failure tolerance: delayed email should not block checkout
  • Bursty traffic: campaigns vs steady API
  • Clear contract: { templateId, recipient, payload, idempotencyKey }
  • Naturally idempotent with dedupe keys

Catalog read service (we waited)

Read patterns were not stable enough for CQRS. Marketing kept changing how variants displayed. Maintaining separate read and write models would have doubled migration work for months.

Image processing worker (success)

CPU-heavy PDF/thumbnail generation does not belong on the API request path. Separate worker with higher memory limit, fed by queue, outputs to object storage. API stores URLs only.


Part 7: Sagas for cross-aggregate workflows

When you must coordinate multiple services without a single database transaction, use a saga, a sequence of local transactions with compensating actions.

Example: split fulfillment across warehouse providers.

  1. Reserve inventory (local TX)
  2. Create warehouse job (local TX)
  3. If warehouse rejects → release inventory (compensate)
async function fulfillOrder(orderId: string) {
  const sagaId = await sagaStore.start("fulfill_order", { orderId });

  try {
    await sagaStore.step(sagaId, "reserve_inventory", () =>
      inventory.reserve(orderId),
    );
    await sagaStore.step(sagaId, "create_warehouse_job", () =>
      warehouse.createJob(orderId),
    );
    await sagaStore.complete(sagaId);
  } catch (err) {
    await sagaStore.compensate(sagaId); // runs registered undo steps in reverse
    throw err;
  }
}

Sagas are complex. Prefer single-service transactions when you can. Use sagas when boundaries are real and stable.


Part 8: Caching layers honestly

Layer Good for Watch out for
HTTP CDN Static assets, public catalog pages Personalized data, auth
Application cache (Redis) Session, rate limits, hot keys Invalidation bugs, tenant keys
Database query cache Repeated identical queries Stale reads after writes
Materialized views Heavy aggregates, dashboards Refresh lag

Cache invalidation on product update:

await db.transaction(async (tx) => {
  await tx.update(products).set(data).where(eq(products.id, id));
});

await cache.del(`org:${tenantId}:product:${id}`);
await cache.delByPrefix(`org:${tenantId}:catalog:list:`);

Write-through vs cache-aside is a trade-off. Document which you use per resource.


Part 9: API contracts between services

If you split, treat every cross-service call as a public API:

// packages/contracts/src/warehouse.ts
export const WarehouseJob = z.object({
  orderId: z.string().uuid(),
  tenantId: z.string().uuid(),
  lines: z.array(
    z.object({
      sku: z.string(),
      quantity: z.number().int().positive(),
    }),
  ),
  shipBy: z.string().datetime(),
});

export type WarehouseJob = z.infer<typeof WarehouseJob>;

Both producer and consumer import the same schema. Version contracts (WarehouseJobV2) when breaking. Timeouts and retries with exponential backoff on callers. Circuit breakers when downstream is unhealthy, so you fail fast instead of hanging checkout.


Part 10: Signs you split too early

  • More time debugging network than building features
  • Most requests need five synchronous service calls
  • No distributed tracing yet, so logs are guesswork
  • Domain model changes every sprint
  • Team is fewer than eight engineers and one deployable works fine

Signs you split too late

  • Deploy requires coordination across four teams
  • One subsystem’s memory leak takes down unrelated APIs
  • Parts need incompatible scaling (image processing vs auth)
  • You already built ad-hoc async glue because sync calls timeout
  • Schema migrations paralyze everyone because one database serves everything

Part 11: Load and capacity thinking at design time

Even in a monolith, design for horizontal scaling early:

  • Stateless HTTP handlers
  • Sessions in Redis or JWT, not server RAM
  • Uploads to object storage, not local disk
  • Background work in queues, not setTimeout in request

For read-heavy endpoints, pagination with cursors (not offset), read replicas for reporting, and aggressive indexing on proven query patterns. See the database post for detail.

Rate limiting at the edge:

const limiter = rateLimit({
  key: (req) => `${req.tenantId}:${req.ip}`,
  limit: 100,
  windowMs: 60_000,
});

Protects shared infrastructure from one noisy tenant.


The quiet goal

Good boundaries make change boring. Deploy notifications without touching orders. Add a warehouse provider without redeploying checkout. Scale reporting without sizing up the write path.

Bad boundaries make everything harder: a distributed monolith with worse observability and the same coupled releases.

I do not aim for a beautiful architecture diagram. I aim for seams that match how the business actually moves, with async delivery and idempotency as default glue when work crosses them.

That is usually enough to grow for years before the next hard conversation, and when that conversation comes, you will have real data on what changes together, not just opinions.