Our services
If you can think it, we can make it brainsoft.
If you can think it, we can make it brainsoft.
Written By: BrainSoft In Engineering
Every few months someone on a team I work with proposes splitting the product into services. The reasoning is usually the same: the codebase is getting messy, deploys feel risky, and microservices are what serious companies do. Sometimes that is correct. Often it is a way to trade one set of problems for a harder, more expensive set.
A monolith is still the right call when you have one team, an unclear domain, and no scaling bottleneck that a bigger box or a read replica cannot fix. Start with a single deployable, keep module boundaries clean inside it, and split only when a specific part of the system has a reason to live on its own.
The complaints about monoliths are real, they just get blamed on the wrong thing. A tangled codebase is a design problem, not a deployment-topology problem. Moving that tangle into twelve services gives you twelve tangles plus a network between them.
What you do pay for with a single deployable:
Against that, you get transactions that actually work, no distributed tracing to set up before you can debug a request, and a local dev environment that is a single command. When we onboard someone onto a well-structured monolith, they are productive in days.
We look for concrete signals rather than a feeling. If most of these hold, splitting now is premature:
That last point matters more than people expect. The strongest argument for extracting a service is that a piece of the system genuinely needs to ship on its own schedule, or needs a different runtime, or has a failure mode you must contain. Everything else is usually solvable inside the app.
If the database is the bottleneck, that is a database problem. Add an index, add a read replica, cache the expensive query. None of that requires a service boundary.
The reason monoliths get a bad reputation is that people let them rot. The fix is boring and it works: enforce module boundaries in code, not in prose. In a Python codebase we might use import linter rules; in TypeScript, an ESLint boundary plugin. The point is that a module can only reach into another module through its public entry point.
# .importlinter
[importlinter]
root_package = app
[importlinter:contract:layers]
name = Layered architecture
type = layers
layers =
app.api
app.services
app.repositories
app.models
Now the boundary is checkable in CI. When someone tries to import a repository directly from an API handler, the build fails and they get a clear message. That is the same discipline you would need with services, minus the network.
Two more habits that keep a single deployable healthy. First, keep the app stateless so you can run several copies behind a load balancer; sessions go in Redis or a signed cookie, uploads go to object storage. Second, make the background work a separate process reading from the same codebase and the same database. That gives you most of the operational benefit of a worker service without a second repo.
# same image, different command
docker run app:latest ./bin/web
docker run app:latest ./bin/worker
We split when there is a reason we can name in one sentence. Examples from real projects: a video transcoding pipeline that needs GPUs and long-running jobs; a billing integration that has to be audited separately; a search index that needs its own scaling curve. In each case the new service had a distinct resource profile or a distinct reason to fail independently.
The extraction itself is unglamorous. You put a seam in the monolith first, usually an interface with one implementation, and route all calls through it. Then you move that implementation behind an HTTP or queue boundary and keep the interface. If the seam was clean, the callers do not change. If it was not clean, you just learned something valuable before paying for a network hop.
This is also where a lot of teams ask for help, because doing it well means CI changes, observability, and deployment work at the same time. If that is where you are, our services cover that ground, but you can also do the first extraction yourself and see how it feels.
The honest summary: a monolith is a default, not a compromise. It stops being the right default when a specific, nameable part of your system needs to be independent. Until then, keep one repo, one pipeline, and hard module boundaries. You will ship faster, and you will still be able to split later because the seams are already there.
No. A stateless app behind a load balancer scales horizontally by running more copies, and most systems hit a database limit long before an application limit. Add indexes, caching, and read replicas first. If one specific workload needs a different scaling curve, extract that workload rather than the whole app.
When you can state the reason in one sentence. Separate release cadence, a different runtime or hardware profile, or a failure mode that must be contained. If the reason is that the code feels messy, that is a design problem you can fix inside the monolith, and it is cheaper to fix there.
Yes, and it is usually easier than the alternative. One pipeline, one artifact, one rollback command. Add automated tests, run migrations separately from the deploy, and ship behind a feature flag so a bad change can be turned off without a rollback. That covers most of the risk people associate with monolith deploys.