Twenty-four modules, two processes
Someone asked me, in a room where it was a reasonable thing to ask, why a platform with two dozen modules isn't two dozen services.
It's a fair question. Written out, the scope invites it: identity and access, tenancy, client onboarding and KYC, master data, order management, transaction processing, corporate actions, holdings and analytics, RM and customer dashboards, reporting, revenue and incentives, CRM, advisory, communications. Across twelve financial products, from mutual funds and direct equity through AIFs, PMS, structured products, NPS and insurance. That reads like an architecture diagram with a lot of boxes in it.
It's one NestJS application. Around three and a half thousand TypeScript files, one Postgres database, and two processes. The decision came down to three things, and only one of them is technical.
The ledger doesn't have a seam in it
Look at the middle of that list in order: order, transaction processing, corporate actions, holdings, revenue.
Those aren't five domains that occasionally talk. They're five views of one thing. An order becomes a confirmed transaction, the transaction moves a position, the position is valued against a NAV, a corporate action rewrites the position, and the commission owed to a distributor is computed downstream of all of it. A single backdated transaction reaches every one of them.
Make each a service and every operation that used to be a database transaction becomes a distributed one. Applying a corporate action that arrived late means coordinating writes across three services with no shared transaction boundary. The standard answer is a saga with compensating actions, and I want to be precise about what that means here: you are building a system where a partial failure leaves a client's holdings temporarily wrong, and relying on a compensation step to put them right.
For a shopping cart, fine. For units in somebody's portfolio, that window is a period during which a relationship manager can open a screen and read a number that isn't true. There is no good way to explain that to an advisor.
So the strongest argument for keeping this together isn't performance or simplicity. It's that a consistency boundary is a real thing in this domain, it wraps most of the platform, and cutting it into services doesn't remove the requirement. It relocates it into application code, where it's harder to see and impossible to enforce.
Microservices solve a problem we don't have
The second reason: microservices are an organisational solution.
They exist so that many teams can deploy independently without coordinating. That's a genuine problem and the architecture genuinely solves it, at the cost of network calls where you had function calls, distributed tracing where you had a stack trace, versioned contracts between things that used to be a shared type, and an operational surface that needs people to run it.
We don't have that problem. We have a small team building deep domain complexity, and the difficulty here isn't coordinating deploys. It's that corporate action processing across twelve asset classes is genuinely intricate, that AIF drawdowns and bond coupon accrual and SIP mandates all behave differently, and that being wrong is unacceptable. Adopting an architecture designed for inter-team coordination when your bottleneck is domain modelling is a permanent tax against a problem you'd like to have one day.
There's a glib version of this that goes "you're not Netflix". The useful version is: pick the architecture that addresses your actual constraint. Ours was that most of the platform had to exist at once, working as one system, built by a team that fits in a room.
Where the software has to run
The third reason would settle it on its own, and it's the one that isn't visible from outside the product.
The platform ships in more than one deployment mode. There's the multi-tenant SaaS, and there are white-labelled enterprise deployments for firms that want it in their own environment, some of which have their own infrastructure policies.
A monolith in that world is one artifact, a database, and a runbook somebody can read in a morning. The same product as two dozen services is a container orchestrator, a service mesh, distributed tracing, a message broker, and two dozen sets of health checks and secrets, all to be installed, monitored and upgraded inside somebody else's data centre by somebody else's ops team.
That isn't a harder deployment. It's a different product, with a different sales cycle and a different support contract.
Modular, which is the part that matters
One deployable is not one big file, and that distinction is the whole design.
Modules own their area and talk through published interfaces rather than reaching into each other. Business logic never imports the database schema directly; it injects the connection through a single token, so schema files stay declarations and nothing downstream depends on table shapes it doesn't own.
The pattern I'd point to as the clearest example is file storage. Every uploaded object goes to a tenant-scoped key, and there is exactly one function that builds those keys. Nothing templates a path inline. Signed URL generation and deletion take the requesting tenant and check it against the key before doing anything. That's one choke point, in one file, instead of a rule about paths that every developer has to remember.
Tenant isolation works the same way, except the enforcement is below the application entirely. Row-level security policies live in the database, and because the ORM's migration generator can't express RLS, triggers or partial indexes, those objects are applied by a separate step with a CI check that reports drift. A policy that quietly stopped being applied would otherwise be invisible: you'd have every table, and nothing enforcing anything.
Some of the boundaries are enforced by tests. There's an invariant spec that fails the build if anyone reintroduces implicit permission grants on user creation, because that rule is the kind of thing a reasonable person would undo while fixing something else, six months after the reasoning was written down. A test that fails the build is a stronger statement than a comment.
The split we did make
There are two processes, and the axis they split on is more interesting than the monolith decision.
The API server runs HTTP. The worker is a headless context that processes queues: email, notifications, exports, bulk master uploads, bank verification callbacks. Same codebase, different entry point, and the worker deliberately doesn't load the HTTP modules at all.
Neither of those is a service in the domain sense. They're the same code deployed twice, doing work with different operational characteristics. A bulk upload chews CPU and memory in bursts and has no business competing with a dashboard request. Report generation is a different workload from an API call. None of that requires a network boundary between domains; it requires the expensive work to run somewhere else.
That's the axis I'd argue for generally. Split on operational profile — memory, latency tolerance, failure isolation, scaling shape — rather than on domain nouns. Domain nouns give you a diagram that matches the org chart and a distributed transaction where the business logic used to be.
The cost of the two-process model is a real papercut: a new queue needs providers registered in both the API module tree and the worker's, and forgetting one produces a job that enqueues fine and is never consumed. It's written down because it has caught people, including me.
What it costs
I don't want to present this as free.
One deploy means one blast radius. A bad release affects everything, so the release process carries weight a small team would rather not carry, and feature flags do a lot of work.
The test suite grows into a wall. It's already the slowest part of the development loop and it will get worse, and running the full thing before every commit stops being viable well before anyone admits it.
No independent scaling within a process. Reporting is enormously more expensive than the CRM, and in one runtime they share resources whether that's sensible or not. This is the cost I feel most often.
And the boundaries are voluntary in a way service boundaries aren't. Nothing physically prevents someone from writing a cross-module query. In a distributed system that mistake is impossible; here it's merely discouraged, and discipline is a weaker guarantee than a network. The mitigation is to put the important invariants somewhere that isn't discipline — the database, a key builder, a test that fails the build — and to accept that the rest rests on review.
What would change my mind
I'd rather name the conditions than defend the position indefinitely.
If a module needs to scale by an order of magnitude beyond the rest and can't share a runtime, it goes. Analytics and reporting are the likeliest candidates as data volumes grow.
If a module ends up owned by a separate team with a genuinely different release cadence, it goes, because at that point the coordination cost is real rather than hypothetical.
If a module acquires materially different compliance requirements — different data residency, a different audit boundary — it goes, and it goes early, because retrofitting that is worse than anything else on this list.
And if the build and test loop crosses the threshold where people start avoiding it, that's a signal on its own, independent of any architectural argument.
None of those has happened yet. When one does, the module boundaries are already drawn and the extraction is a week of work rather than a rewrite. Drawing them properly while everything still lives in one process was the entire point.