SaaS Architecture Article · 5 min read

Why clean module boundaries matter more than separate deployments: capability-based modules, owned data, events and the outbox, module-scoped transactions, and when to extract a service.

Published
Reading time5 min
Views70
Written byAhmad Alqunbar
Architecture: Boundaries first, microservices later
Fig. 01 — Architecture: Boundaries first, microservices laterSaaS Architecture

Many teams reach for microservices to fix a codebase that has become hard to change. Often they end up with the same tangle, now spread over a network, with distributed transactions, versioned APIs between teams of three people, and an outage every time one service deploys.

The problem was rarely that everything ran in one process. It was that nothing had boundaries. A modular monolith fixes the boundaries first and keeps the single deployment, and for most products I have worked on, it is the better starting point.

What a modular monolith is

One application, one deployment, one database server, split internally into modules that each own a business capability. In a commerce product that might be Catalog, Orders, Inventory, Payments, Customers, and Notifications.

Each module owns its data, exposes a small public interface, and hides everything else. Other modules use that interface and nothing more. The rules are the same ones microservices enforce with a network, except here they are enforced with code structure and tests, and breaking a rule fails a build instead of a production request.

Draw boundaries around capabilities, not layers

A codebase split into controllers, models, and services folders has layers, not boundaries. Every feature still touches every folder, and every folder knows about everything.

Split by what the business does instead. Ask what changes together. Pricing rules, discount eligibility, and tax calculation change together, so they belong in one module. Stock movements and warehouse transfers change together, so they belong in another. A good boundary is one where most changes stay inside a single module.

Each module owns its tables

This is the rule that makes the rest work. The Inventory module owns the stock tables. No other module reads or writes them directly, not even "just one quick join for a report".

Other modules ask Inventory through its public interface: availableQuantity(product, location), reserve(order). When Inventory changes its schema, only Inventory changes. When reporting needs data from several modules, build a read model fed by events, rather than reaching into other modules' tables.

Talk through interfaces and events

Modules communicate in two ways.

Direct calls for questions. Orders asks Payments, "is this order paid?", through a public method. It is in-process, fast, and type-checked.

Events for reactions. When Payments confirms a payment, it publishes PaymentCaptured. Orders marks the order paid, Inventory commits the reservation, and Notifications sends the receipt. Payments doesn't know any of them exist. Adding a loyalty module later means adding a listener, not editing Payments.

If an event has to leave the process, for a queue, a webhook, or another system, write it to an outbox table in the same transaction as the change that caused it, and publish from there. Then a crash between "saved" and "published" can't lose the event.

Keep transactions inside a module

A database transaction should stay within one module's tables. When a business process spans modules, such as checkout touching orders, payments, and inventory, model it as a sequence of steps connected by events, with a compensating step for each failure: release the stock if the payment fails, refund the payment if fulfilment is impossible.

That feels like extra work in a monolith. It is also exactly the design you need if a module ever becomes a separate service, which is the point.

Enforce the boundaries automatically

Boundaries that rely on discipline erode within a few months. Make them checkable:

  • Each module has a public namespace or package, and everything else is internal.
  • Architecture tests fail the build when one module imports another module's internals, or when code queries another module's tables.
  • Code review checks new dependencies between modules as seriously as new database columns.

When to extract a service

A module earns its own deployment when there is a concrete reason:

  • It needs to scale differently, such as a CPU-heavy reporting engine or an AI inference workload.
  • It needs a different technology, such as a Python service for machine learning next to a PHP or Node application.
  • A separate team owns it and needs its own release schedule.
  • It has different reliability or security requirements, such as payment processing that must stay isolated.

Because the module already owns its data and talks through interfaces and events, extraction becomes mostly moving code and replacing in-process calls with network calls. Without the boundaries, extraction is a rewrite.

The checklist

  • Modules follow business capabilities, not technical layers.
  • Each module owns its tables; no cross-module joins or writes.
  • Direct calls for questions, events for reactions, an outbox for anything that leaves the process.
  • Transactions stay within a module; cross-module processes have compensating steps.
  • Architecture tests enforce the boundaries in the build.
  • A service is extracted only for scaling, technology, ownership, or isolation.

Start with clean boundaries in one deployable. You can always split later, and when you do, it will be a deployment decision instead of a rescue project.