Skip to main content

Command Palette

Search for a command to run...

Composable Architecture Without Client-Heavy Bloat

Why small teams should stay server-first longer and separate modules before services

Published
9 min read
Composable Architecture Without Client-Heavy Bloat

Composable Architecture Without Client-Heavy Bloat

Why most small teams should start server-first, stay modular, and stop confusing code boundaries with network boundaries.

A lot of teams say they want a composable architecture.

What they often build instead is distributed coupling.

They split the app into a separate frontend and backend early, push everything through HTTP, duplicate validation and state handling on both sides, and call it flexibility. In reality, they did not remove coupling. They just moved it into JSON payloads, API contracts, and deployment coordination.

That is not composability. It is overhead.

For most products, especially those built by small teams, a better default is much simpler:

  • keep the app server-first
  • keep the core logic modular
  • keep most communication in-process
  • extract separate services only when there is real operational pressure

This gives you a system that is easier to ship, easier to reason about, and still flexible enough to grow a new UI or new interfaces later.

The mistake: confusing code boundaries with deployment boundaries

This is the root confusion.

People see a clean architecture diagram and assume the boxes must be separate processes talking over HTTP.

They do not.

A boundary in code does not automatically mean a boundary over the network.

Those are two different questions:

  1. Where should responsibilities be separated in the codebase?
  2. Which parts actually need to be deployed and operated independently?

Most teams answer question two far too early.

You can have a well-structured, modular app where the parts are cleanly separated in code but still run inside one deployable system. In fact, that is usually the better starting point.

What server-first actually means

Server-first does not mean shoving business logic into templates and calling it a day.

It does not mean building a giant tangled monolith where controllers, views, and database models all leak into each other.

It means:

  • the server is allowed to assemble the page
  • the browser is not forced to become a second application runtime
  • the core business logic is not tied to any one delivery surface

That last point matters most.

The goal is not “HTML everywhere forever.”

The goal is to keep the core of the app stable, while making it possible to attach different adapters around it.

Those adapters might be:

  • server-rendered HTML
  • a JSON API
  • a background job
  • an admin interface
  • a CLI command
  • a future mobile client

If those are all thin layers over the same application core, you have optionality without paying the full cost upfront.

What “composable” should mean in practice

When developers say “composable,” they often mean one of two things.

The first is infrastructure composability:

  • swap the database
  • change the ORM
  • replace the payment provider
  • move search to a different backend

The second is product composability:

  • redesign the UI
  • add a different frontend later
  • expose an API
  • support a second interface without rewriting the app

Both are valid goals.

But neither one requires you to split into “frontend” and “backend” as separate systems from the beginning.

What you actually need is a stable application core and clean seams around it.

The better default: a modular monolith

For most business apps, the right default is not a fat client and not a microservice fleet.

It is a modular monolith.

One deployable app. Clean internal modules. Clear boundaries. Thin adapters.

That means the system is structured around things like:

  • domain and application logic
  • ports or interfaces for things that vary
  • concrete adapters for the web, database, payments, jobs, and so on

Not around a premature split into two independently deployed apps.

A simple shape looks like this:

/domain
  listing.ts
  billing.ts
  permissions.ts

/application
  createListing.ts
  approveListing.ts
  chargePlan.ts

/ports
  ListingRepo.ts
  BillingGateway.ts
  SearchIndex.ts

/adapters
  /db
    ListingRepoPostgres.ts
  /web-html
    listingsPage.ts
  /web-api
    listingsApi.ts
  /jobs
    reindexListings.ts

The important part is not the folder names. The important part is the direction of dependency.

The application core should not care whether the request came from an HTML page, an API route, or a worker.

A concrete example

Imagine you are building a listings product.

You have:

  • a public listings page
  • an internal admin view
  • a flow for creating a listing
  • billing rules that determine whether a user can post more listings
  • search filters and moderation states

A lot of teams would jump straight to:

  • React frontend
  • JSON API backend
  • maybe separate admin frontend too

But if you are a small team, that usually creates more problems than it solves.

A cleaner shape is this:

  • CreateListing application service
  • ListingRepo interface
  • BillingAccess policy/service
  • HtmlListingsController
  • maybe later ApiListingsController

Then the flows look like this:

HtmlListingsController -> CreateListing -> ListingRepo -> DB
ApiListingsController  -> CreateListing -> ListingRepo -> DB
AdminJob/Worker        -> CreateListing -> ListingRepo -> DB

All three use the same business capability.

That is real composability.

You can redesign the UI later. You can add an API later. You can migrate route by route. You can even remove the HTML controller for one area if a richer frontend becomes worth it.

The core stays intact.

No, modules inside one app should not talk over HTTP

This is where many people get lost.

If you have these parts inside the same application:

  • HTML controller
  • API controller
  • application service
  • repository

then they usually should not communicate via HTTP.

They should call each other directly in-process.

Like this:

HtmlController -> AppService -> Repo -> DB

not like this:

HtmlController -(HTTP)-> AppService -(HTTP)-> Repo

HTTP is for process boundaries.

Inside one app, use direct calls.

This seems obvious once stated plainly, but a lot of teams blur the line between a conceptual boundary and a network boundary.

That confusion creates fake microservices inside one product.

The result is predictable:

  • more latency
  • more failure modes
  • more serialization and contract maintenance
  • harder debugging
  • weaker transactions
  • slower shipping

You bought distributed systems problems without earning the benefits.

When should a module stay in-process?

Most core business modules should stay in the modulith longer than people think.

Keep a module in-process when most of these are true:

  • it participates in the same user request or transaction
  • it shares the core data model deeply
  • it does not need independent scaling
  • it is owned by the same team
  • there is no real second consumer yet
  • a network hop would mostly add friction

This usually applies to things like:

  • users
  • permissions
  • billing logic
  • listings or content entities
  • moderation
  • account state
  • admin workflows

These are not great early service boundaries. They are usually core modules of the same application.

What is a good candidate for extraction?

Separate services make more sense when something is operationally distinct.

That usually means it is:

  • async
  • bursty
  • resource-heavy
  • failure-prone
  • dependent on third-party systems
  • running on a different cadence from the main user-facing app

Examples:

  • search indexing
  • media or PDF processing
  • webhook consumers
  • crawler/scraper pipelines
  • AI enrichment jobs
  • analytics ingestion
  • async notifications

These are much better candidates for workers or separate services.

The pattern here is simple:

If a module mostly answers what the business does, keep it in the modulith by default.

If it mostly answers how the system processes heavy or operationally distinct work, it is a better extraction candidate.

You can still replace the frontend later

This is the point people are usually worried about.

They want to avoid getting trapped in one presentation layer.

Fair concern. But the solution is not to split everything on day one.

The solution is to keep the presentation layer thin.

If your server-rendered HTML controller is only an adapter that calls application services, you can later:

  • replace those routes with an API-driven frontend
  • run old HTML and new UI side by side during migration
  • keep some parts server-rendered and make only a complex area more interactive

That is often the best real-world setup.

For example:

  • marketing pages: server-rendered
  • account and settings: server-rendered
  • complex dashboard or editor: richer client-side frontend

Not every page needs the same architecture.

That is another reason to resist defaulting to an all-client app too early.

Why teams over-split

Teams usually do not split early because they are stupid. They split early because the story sounds reasonable.

“We want flexibility.”

“We might need mobile later.”

“We do not want a monolith.”

“We want the frontend to be replaceable.”

All of that sounds smart. The problem is that these are often hypothetical benefits traded for immediate, certain costs.

Those costs are real:

  • duplicated validation and types
  • more auth and session complexity
  • more code to coordinate
  • slower local development
  • harder end-to-end reasoning
  • more release friction
  • more bugs at boundaries

You should not pay those costs based on imagined future consumers.

Build the second consumer when it becomes real.

Until then, keep the architecture honest.

A good migration path

The right progression for most products looks like this:

1. Start with a modular monolith

One app. Clear boundaries. Server-first by default. Thin controllers.

2. Add API adapters where there is a real need

Maybe a richer frontend is justified for one area. Fine. Add the API adapter there.

3. Extract operational subsystems first

Workers, indexing, AI jobs, media processing, webhook handling.

4. Extract business-domain services only when real pressure proves it

That pressure might be:

  • independent ownership
  • separate scaling needs
  • narrow stable contracts
  • truly different deployment cadence

Until then, keep the domain close together.

The rule I would use

If you are unsure, default to this:

Keep business logic and core workflows in one modular, server-first app. Extract only the parts that are operationally distinct, async, or heavy.

That rule will save most small teams from a lot of self-inflicted architecture pain.

Closing thought

A composable architecture is not one where everything talks over HTTP.

It is one where the core of the app remains stable while the surfaces and infrastructure around it can change.

That means:

  • clean code boundaries
  • thin adapters
  • server-first by default
  • minimal client-side complexity unless it clearly pays for itself
  • service boundaries introduced for real reasons, not architectural fashion

That is the boring answer.

It is also the one that usually works.