Step 15 of 24
Modular design
Split a codebase into modules with small public APIs and hidden implementations: package by feature, api/implementation splits, enforced boundaries and the modular monolith.
As a codebase grows past what one person can hold in their head, its structure decides how fast everyone can work. In a well-modularised system you can change the payments code without reading the catalogue code, a new engineer can own one feature in their first week, and two teams can ship in parallel without stepping on each other. In a poorly modularised one, every change ripples everywhere. This lesson covers what makes a good module, packaging by feature instead of by layer, the api/implementation split used in large apps like Gojek's, how to enforce boundaries in Go and TypeScript, and the modular monolith.
What a module is
A module is a unit of code with a public interface that other code may use and an implementation that it hides. It might be a Go package, a TypeScript folder with an index.ts, a Gradle module in an Android app, or a service. The idea goes back to David Parnas's 1972 paper on information hiding: decompose a system so that each module hides a design decision that is likely to change — a storage format, an algorithm, a vendor — behind an interface that isn't.
The measure of a module is the ratio of what it does to what it exposes. John Ousterhout calls this depth:
- A deep module has a small interface hiding a lot of functionality. Go's
io.Reader(one method) ornet/http'shttp.Get(one function hiding connection pooling, TLS, redirects and HTTP/2) are deep. - A shallow module has an interface nearly as complex as its implementation: a
UserManagerwith 30 public methods that each wrap one SQL query. It adds a layer without hiding anything.
Deep modules reduce the amount the rest of the system must know — which is the whole point. When you design a module, start with the question: what is the smallest set of things callers need? Everything else is private.
Package by layer vs package by feature
The most common first structure for a backend is package by layer:
internal/
handlers/ order_handler.go product_handler.go user_handler.go
services/ order_service.go product_service.go user_service.go
repositories/ order_repo.go product_repo.go user_repo.go
models/ order.go product.go user.goIt looks tidy, but it has low cohesion: adding a field to orders touches four packages, everything in models is visible to everything else, and nothing stops product_service.go from reaching into the order tables. Layers are cross-cutting; features are what actually change together.
Package by feature groups code by what it does for the business:
internal/
catalog/ handler.go service.go store.go product.go
ordering/ handler.go service.go store.go order.go
accounts/ handler.go service.go store.go user.go
platform/ database/ httpserver/ config/ (shared infrastructure)Now a change to ordering usually touches one directory, each feature can keep most of its identifiers unexported, and the folder tree tells a newcomer what the system does. Layers still exist — inside each feature. The package by feature note compares the two in more detail.
The api/implementation split
Package by feature solves cohesion. Large codebases need one more step: controlling what features may depend on in each other. The pattern used by large modular apps — Gojek's Android app is a well-known example — splits each feature into separate modules:
features/
featureA/
api/ # interfaces and models other features may use
implementation/ # screens, view models, repositories, network calls
shared-ui/ # UI components other features may embed
featureB/
api/
implementation/
shared-ui/
core/
network/ config/ shared-resources/
app/ # wires implementations to APIs at startupThe rules are simple and enforced by the build system:
- A feature's
implementationmay depend on other features'api(andshared-ui), never on theirimplementation. apimodules are small and stable: interfaces, data types, navigation entry points.- Only the
appmodule sees every implementation, and connects them through dependency injection.
The payoffs are concrete. Build speed: changing featureA's implementation recompiles only that module, because nothing else depends on it — in a large Android or Gradle build that's minutes per change. Team autonomy: the team owning featureA can restructure its implementation freely, as long as its api holds. Enforced boundaries: an illegal import doesn't compile, so the architecture can't erode one shortcut at a time. Replaceability: a test, a demo app, or a different market can plug in a different implementation of an api.
This is the dependency inversion principle applied at module scale: features depend on abstractions, not on each other's internals.
Enforcing boundaries in Go
Go gives you a built-in tool: an internal directory can only be imported by code rooted at its parent. Use it to give each feature a public API and a private implementation:
shop/
cmd/shop/main.go # wiring: the only place that sees everything
internal/
ordering/
ordering.go # public API: Service interface, Order type
internal/
store/postgres.go # importable only from internal/ordering/...
httpapi/handler.go
payments/
payments.go
internal/...// internal/ordering/ordering.go — the module's public surface
package ordering
type Order struct {
ID string
CustomerID string
Total int64
Status Status
}
type Service interface {
Place(ctx context.Context, customerID string, items []Item) (Order, error)
Get(ctx context.Context, id string) (Order, error)
}Another feature, say payments, can import shop/internal/ordering and use ordering.Service, but the compiler rejects any import of shop/internal/ordering/internal/store. For rules the compiler can't express ("catalog must never import payments"), add an architecture linter such as depguard (in golangci-lint) or a small test that walks imports with go list -deps and fails on forbidden edges.
Two more Go-specific habits help:
- Keep identifiers unexported by default. Export only what another package actually uses. Every exported name is a promise — see Hyrum's law.
- Avoid
util,commonandhelperspackages. They have no cohesion, become dependency magnets, and eventually everything imports them. Put a helper in the package that uses it, or in a package named for what it does (money,slug).
Enforcing boundaries in TypeScript
A frontend can follow the same shape. Give each feature folder a single public entry point and forbid deep imports:
src/features/
checkout/
index.ts # export { CheckoutPage, useCart } — the public API
components/ hooks/ api/
catalog/
index.ts
...// eslint.config.js (flat config)
export default [
{
files: ['src/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': ['error', {
patterns: [{ group: ['@/features/*/*'], message: 'Import a feature through its index.ts.' }],
}],
},
},
]@/features/catalog is allowed; @/features/catalog/components/ProductCard is a lint error. For stricter boundaries (which features may import which), tools like eslint-plugin-boundaries or Nx module boundaries encode the full dependency graph, and monorepos can split features into separate packages with their own package.json exports.
Dependencies should point one way
Whatever the language, draw the module dependency graph and check two things:
- No cycles. If
orderingimportspaymentsandpaymentsimportsordering, neither can be understood, tested or extracted alone. (Go forbids import cycles outright; most other ecosystems allow them, so check.) Break a cycle by moving the shared concept into a third module, or by inverting one direction with an interface or an event. - Stable things at the bottom. Modules many others depend on should change rarely — core domain types, platform utilities. Volatile modules (a promo campaign, an experiment) should sit at the top where nothing depends on them.
The modular monolith
You don't need microservices to get these benefits. A modular monolith is one deployable application whose code is split into strictly separated modules: each owns its own tables, talks to others only through their public API (function calls or in-process events), and never reaches into another's data. You get team autonomy and clear ownership, while keeping in-process calls, one deployment, one database transaction when you need it, and simple debugging.
It's also the safest path to services: a module with a clean API and its own data can be extracted into a service when there's a real reason — independent scaling, a separate team's release cadence, a security boundary — by replacing its in-process API with a network one. Start-ups that begin with microservices often end up with a distributed monolith: all the coupling, plus network failures. The monolith vs microservices note covers the trade-off, and Conway's law explains why module boundaries tend to follow team boundaries.
Practice
You're given a Go backend packaged by layer: handlers/, services/, repositories/, models/, covering users, products, orders and payments. Ordering code currently queries the products table directly to read prices. Describe the target module structure, the public API of the catalog module that ordering would use, and the order of steps to migrate without a big-bang rewrite.
Show solution
Target structure — package by feature, with private implementations:
internal/
accounts/ accounts.go internal/{store,httpapi}
catalog/ catalog.go internal/{store,httpapi}
ordering/ ordering.go internal/{store,httpapi}
payments/ payments.go internal/{store,httpapi}
platform/ database/ httpserver/ config/
cmd/shop/main.go # constructs each module and wires them togetherCatalog's public API — only what ordering actually needs, declared on the consumer side if you want to keep ordering independent of catalog's package:
// in ordering: what ordering needs from the catalogue
type PriceLookup interface {
Prices(ctx context.Context, productIDs []string) (map[string]int64, error)
}catalog.Service implements Prices; main passes it to ordering.NewService. Ordering no longer knows the products table exists, so catalog can change its schema freely.
Migration steps, each a small PR that leaves everything working:
- Add an import-boundary check (a test using
go listor depguard) in report mode to see current violations. - Create
internal/catalogand move product handler, service, repository and model into it. Callers update imports; behaviour is unchanged. - Add
catalog.Service.Pricesand switch ordering from its direct SQL to thePriceLookupinterface. Delete ordering's product query. - Move
catalog's store and handler undercatalog/internal/so the compiler enforces the boundary. - Repeat for accounts, ordering and payments, one module per PR.
- Switch the boundary check to fail mode in CI, so the structure can't erode.
Key takeaways
- A module hides a design decision behind a small public interface; prefer deep modules with little surface and much behaviour.
- Package by feature, not by layer, so code that changes together lives together; keep layers inside each feature.
- Split large features into
apiandimplementationmodules; features depend only on each other's APIs, and only the app wires implementations together. - Enforce boundaries with tools: Go's
internal/directories and import linters; TypeScript entry points withno-restricted-importsor boundary plugins. - Keep the dependency graph acyclic, with stable modules at the bottom; avoid
utilandcommondumping grounds. - A modular monolith gives most of the benefits of services without the network, and is the safest path to extracting one later.
Check yourself
Quiz
0/4 correct · 4 left
1.In a codebase split into api and implementation modules per feature, which dependency is allowed?
2.What does an internal directory at shop/internal/ordering/internal/store enforce in Go?
3.Why is package by layer (handlers/, services/, repositories/, models/) usually worse than package by feature as a codebase grows?
4.What best describes a modular monolith?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
- Monolith vs microservicesWhat splitting a service buys you, what it costs, and the modular monolith in between.
- Coupling and cohesionKeep what changes together in one place and minimise what each part knows about the others.
- Dependency inversion principleBusiness logic owns the interfaces it needs and infrastructure implements them, so dependencies point toward policy.
- Package by featureOrganise code by business capability instead of technical layer, so a change stays in one place and boundaries can be enforced.
- Bounded contextsA boundary inside which one model and its language are consistent — the main tool for splitting large systems.
- Conway's lawA system’s structure ends up mirroring the communication structure of the organization that builds it.
- Hyrum's lawWith enough users, every observable behavior of your API is depended on by someone, whatever the contract says.
Finished reading?
Mark it complete to fill in your roadmap.