Strategic Monolith + Satellites
Most architecture discussions for new products begin with "should this be a microservice"? It is almost always the wrong first question. The right first question is: what needs to own state, and what just needs to do work?
This article describes a topology for a single application: a monolithic core for state and orchestration, with satellites and data components at the boundary. Larger systems may comprise several of these; the architecture applies within each one. It is not a monolith-vs-microservices argument. It is a pattern that gives you distributed compute and specialised storage without distributed state ownership.
The Architecture
The architecture has three parts. Understanding what each one owns, and what it does not, is what makes the pattern work.
The monolithic core owns everything that matters: all persistent state, workflow, orchestration, business rules, and transition guards. It is the single source of truth. State changes happen here, decisions are made here, and when something goes wrong in production, this is where you look.
Satellites handle specialised compute. They are stateless (excluding local caches): they receive a request, do work, and return a result. They do not own the "source of truth" data and state.
Data components handle specialised storage concerns. They own a specific data touchpoint and expose a clean interface through the data layer. They are not peers to the core; they are ingredients behind an abstraction boundary.
- Elasticsearch for full-text search
- A graph database for relationship traversal
- A time-series store for metrics or sensor data
- A purpose-built service wrapping any of the above
The core calls satellites directly: HTTP/RPC for latency-sensitive work, a database queue for anything that can wait. Data components are accessed through the data layer, which means the application never knows or cares what sits behind them.
The core can run concurrently. Multiple workers, thread pools, async frameworks; none of these require distributing your state. Concurrency is not the same as distribution, and the conflation of the two is one of the primary drivers of premature architectural complexity.
The benefits are direct consequences of the structure:
- No distributed transactions. The core is the single source of truth; all state changes happen in one place.
- No coordination headaches. You do not need consensus between services to make a decision.
- Clear ownership. If something is wrong with the data, you know where to look.
- Independently deployable boundaries. You can update the risk scoring model, swap the search backend, or replace a data component without touching the core.
- Technology freedom at the boundary. Each component uses whatever suits its concern. The core does not care what is behind the interface.
Why "Strategic"?
"Monolith" carries baggage. It conjures images of tangled legacy systems that nobody wants to touch. This is not that.
A strategic monolith is a deliberate architectural choice. It is monolithic in one specific sense: it owns all persistent state and all decision-making in a single deployable unit. It is not monolithic in the sense of being a ball of mud with no internal structure.
The internal structure matters. A well-factored monolith has clear module boundaries, a clean data layer, explicit dependency injection, and disciplined separation between orchestration and side effects. The code inside the core can be as well-organised as any microservice architecture; it simply does not pay the tax of distributed coordination.
At a previous fintech company, I built a data orchestration system based on this exact pattern. Its responsibility was to accept a stream of incoming articles from upstream data extraction systems, store the source of truth, call satellite ML services to clean data and link related records, maintain audit logs and history, and publish the results to a downstream search system for client consumption.
That system has operated without complaint for nearly a decade. It requires next to no maintenance, is easy for developers to understand and work with, and has processed hundreds of millions of source data points. It supports many tens of millions of dollars of revenue to this day (at time of writing).
The strategic value is optionality. A monolith with clean internal boundaries can be decomposed later if genuine scaling constraints demand it. A prematurely distributed system, by contrast, is extremely expensive to re-consolidate. The asymmetry is stark: splitting a well-structured monolith is a bounded project; reunifying distributed state is often a rewrite.
Earning Satellite Status
The instinct to extract services is strong. Resist it until the justification is concrete.
A satellite earns its place on one of two grounds:
- Resource weight. The work is genuinely heavy: ML inference, OCR, complex document processing. Running it in-process would starve the core of resources (CPU cycles, memory or other).
- Independent deployment. The satellite has its own release cycle. An ML model retraining weekly should not require a full application deployment.
Team ownership can reinforce the case for extraction, but it is not a standalone justification. A data science team owning an ML model that is already heavy and independently deployed? That is all reasons pointing the same way. A separate team working on lightweight business logic that deploys on the same cadence? That is a module boundary, not a service boundary. Be cautious here: "we have a separate team, therefore we need a separate service" is how most premature splits get justified.
Everything else stays in the core as ordinary function calls. Sending an email, generating a PDF, calling a simple REST API, formatting a webhook payload: these are application code. They do not need a service boundary, a queue, or a deployment pipeline of their own.
The test is simple: if you extracted it, would you need to solve any distributed systems problems that do not currently exist? If the answer is yes, the extraction cost is real and the benefit must be proportional.
The Ingredients Follow from the Architecture
Once the topology is clear, the stack choices become obvious.
Start with a relational database. Almost every early-stage product should. A single PostgreSQL instance handles transactional, analytical, search, and even graph workloads adequately until you have genuinely outgrown it. The instinct to reach for a document store, a graph database, or a time-series store before you understand your access patterns is one of the most expensive mistakes a team can make. Think hard before introducing a second storage technology. The reasoning behind this is expanded in Defer the Database, Not the Design.
For side projects, MVPs, and embedded tooling, SQLite carries zero operational overhead and is considerably more capable than most engineers expect.
A clean data layer is the abstraction that holds the architecture together. It insulates the application from storage decisions, makes each component independently testable, and keeps the exit route open when a touchpoint eventually outgrows its current backend. The full pattern is described in How to Build a Data Access Layer.
A database-backed queue is the right tool when work needs async processing or needs to call a satellite at a controlled rate; not a message broker, but a table with a few index tricks. This is covered in depth in Your Database is Already a Queue.
Each ingredient is swappable because the data layer insulates the application from the storage choice beneath it. You add them when the constraints demand it, not when a blog post tells you to.
In Practice: KYC Onboarding
KYC (Know Your Customer) onboarding is a good illustration because the satellite interactions are varied and the boundaries are clear. The same pattern applies to any workflow with side effects that cross a process boundary.
The core owns the workflow: the states a KYC check moves through (SUBMITTED → PROCESSING → APPROVED / REJECTED / REFERRED), the transitions between them, and the rules for what triggers each one.
The interesting architectural question is what lives outside the core, and why.
Document verification is a satellite. It runs an ML model against uploaded identity documents: comparing faces, validating holograms, checking for tampering. The compute is genuinely heavy and the model has its own release cycle. It earns satellite status on both grounds.
Risk scoring is a satellite. It takes customer data and returns a score. The model is complex, independently deployable, and the compute cost justifies separation.
Document correlation is a satellite. It matches uploaded documents against a large corpus using embedding-based similarity search: generating vectors from document content, querying a high-dimensional index, and scoring candidates for duplication or known fraud patterns. The compute is heavy, and the embeddings and corpus update on their own cycle.
Sending a welcome email on approval is not a satellite. An email.send(...) call to an SMTP library or a simple API client is just application code. The monolith handles it without ceremony. The instinct to extract every side effect into a service is one of the most common sources of unnecessary architectural complexity.
The data layer sits behind the provider that stores KYC checks. Whether the checks live in PostgreSQL, migrate to a separate compliance datastore, or are shadowed across two systems during a transition is entirely hidden from the orchestration code. The application does not change when the storage does.
What This Is Not
Not a microservices takedown. The architecture tells you when to distribute; it does not tell you never to. When a satellite or data component genuinely needs to be a separate service, the architecture already accommodates it. The difference is that the decision is made at the boundary, not by default.
Not a constraint on concurrency. The core scales vertically and horizontally with read replicas, worker pools, and connection pooling long before distribution becomes necessary. Scalability is a data layer concern, not an architecture topology concern; see Defer the Database, Not the Design.
Not inflexible. A special-case touchpoint that genuinely needs a graph database or a time-series store becomes a data component: introduced behind the data layer, on its own schedule, without touching the application. Nothing stops you writing a purpose-built service to wrap it; just treat it as a data component, not a peer.
Not a claim about entire platforms. This architecture describes a single application. Larger systems, particularly multi-stage data processing pipelines, may comprise several applications, each following this pattern independently with its own core and satellites. The boundaries between those applications are genuine service boundaries; the point is that the boundaries within each one usually are not.
Not the last word. The architecture gives you a clear signal for when to evolve: when a specific touchpoint is demonstrably breaking under load, and not before.
Testing
A common objection to the monolith is that it becomes hard to test as it grows. The opposite is true if the architecture is right. Two things make this work: an explicit dependency context that makes unit and integration testing simple, and architecture tests that prevent the structure from eroding.
The Context Object
All dependencies are bundled into a single Context object and injected at startup. The data layer, the satellites, and any other collaborators are all accessed through it:
class Context(NamedTuple):
repositories: Repositories
verification: VerificationService
risk_scoring: RiskScoringService
correlation: CorrelationService
repositories is itself a container of data layer providers (KYC checks, customers, documents); its contents are swappable independently, just as the satellites are. The entire execution context of the application is captured in a single object.
In tests, you construct a Context with fakes for every dependency rather than mocking individual calls. Every component is independently substitutable, and the fakes can be feature-complete implementations rather than brittle mock expectations. The monolith becomes easy to reason about: the Context is the complete list of things the application depends on, and nothing is hidden.
This is a direct consequence of the architecture. Because the core accesses satellites through injected services and storage through the data layer, there are no hidden dependencies to stub out and no global state to manage. A test that constructs a Context with fakes has the same shape as production; the only difference is what sits behind the interfaces.
Prefer Fakes over Mocks
A mock verifies that a specific method was called with specific arguments. A fake is a lightweight, working implementation of the same interface.
Mocks test implementation: did the code call verification.verify() with the right customer ID? Fakes test behaviour: given this customer, does the workflow produce the right outcome? When you refactor the internals of a transition, mock-based tests break even if the behaviour is unchanged. Fake-based tests only break if the behaviour actually changes.
A fake is written once, maintained along with the dependency and shared across the entire test suite. It is a real implementation: it accepts the same types, returns the same types, and behaves predictably. When the interface changes, the fake fails to compile rather than silently passing with a stale mock expectation.
Oleksii at tyrrrz.me has made this argument in depth, definitely worth reading!
The Context pattern makes this practical. Because every dependency enters through a single object, building a test context is one constructor call:
def make_test_context(**overrides) -> Context:
defaults = Context(
repositories=FakeRepositories(),
verification=FakeVerificationService({}),
risk_scoring=FakeRiskScoringService(default_score=50),
correlation=FakeCorrelationService(),
)
return defaults._replace(**overrides)
Each test overrides only the dependencies it cares about. The rest are sensible defaults. No mock framework, no patching, no cleanup.
Guarding the Boundaries
A monolith with clean internal structure and a monolith that has decayed into a ball of mud are architecturally identical from the outside. The difference is entirely internal, and internal structure erodes quietly. A new developer takes a shortcut across a module boundary. A rushed feature bypasses the data layer and queries the database directly. Over six months with three teams, the well-factored core becomes the tangled system this architecture was designed to prevent.
Architecture tests are the countermeasure. They are automated checks, run in CI alongside your unit tests, that enforce structural rules about what code is allowed to depend on what.
The rules that matter most for this architecture:
- Data layer enforcement. Application code must access storage through the data layer, never directly. No raw SQL in workflow code, no ORM imports outside the provider implementations.
- Module isolation. Each module exposes a public interface. Other modules may depend on that interface but not on its internals. This is the boundary that prevents a monolith from becoming a distributed monolith in a single process.
- Satellite independence. Satellite code must not import from the core's internals. If a satellite needs something from the core, it receives it as a parameter or through a defined contract.
A simple approach in Python is a test that walks the import graph:
def test_workflows_do_not_bypass_data_layer():
for path in Path("src/workflows").rglob("*.py"):
imports = get_imports(path)
for module in imports:
assert not module.startswith("src.storage"), (
f"{path} imports {module} directly; use the data layer"
)
The implementation of get_imports is a few lines of ast.parse; the point is not the tooling but the habit. In Java, ArchUnit is the standard. In Python, import-linter does the same job declaratively. The specific tool matters less than the principle: if a boundary is important enough to draw on a diagram, it is important enough to test in CI.
These tests are cheap to write, fast to run, and pay for themselves the first time a pull request breaks a boundary that would otherwise have gone unnoticed until it caused a production incident. With multiple teams contributing to the same monolith, they are not optional.
Deviations are Possible
This architecture is a starting point, not a straitjacket. Real systems accumulate history, constraints, and decisions made before any article existed. Sometimes the right answer is a pragmatic deviation.
Some satellites will be less stateless than you would like. Some data components will blur the line with the core. Some boundaries that look clean on a diagram will be messier in code. This is fine, and to be expected.
The point is not purity. It is deliberateness. When you deviate, do it consciously and with a clear reason. Know what you are giving up and document it. A system held together with a bit of pragmatic tape and clear documentation beats a pristine architecture that ships six months late.
The architecture gives you a default. Defaults are valuable precisely because they tell you when you are departing from them.
Summary
Logically distribute compute, not state.
A monolithic core with satellites and data components gives you the scaling benefits of distributed systems without the coordination overhead. The core owns all domain state and orchestration. Satellites handle heavy, independently deployable functions. Data components handle specialised storage behind the data layer. The bar for extraction is concrete: resource weight or independent deployment.
Start with a relational database, add ingredients when the constraints demand it, and keep the exit routes open.
The rest of the series builds on this foundation:
- How to Build a Data Access Layer: the abstraction that makes the architecture work
- Defer the Database, Not the Design: how to evolve the ingredients without rewriting the application
- Your Database is Already a Queue: what async processing looks like within this architecture
- Your System is a State Machine: the mental model that makes this topology obvious
- Events vs Signals: what goes wrong when you reach for a broker before you have earned it
- Idempotency is Not Optional: the discipline that makes state transitions safe
- Audit Trails Done Properly: how to capture and query the record of what happened