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.

18 min 9 sections

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) or net/http's http.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 UserManager with 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:

text
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.go

It 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:

text
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:

text
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 startup

The rules are simple and enforced by the build system:

  • A feature's implementation may depend on other features' api (and shared-ui), never on their implementation.
  • api modules are small and stable: interfaces, data types, navigation entry points.
  • Only the app module 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:

text
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/...
go
// 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, common and helpers packages. 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:

text
src/features/
  checkout/
    index.ts            # export { CheckoutPage, useCart } — the public API
    components/  hooks/  api/
  catalog/
    index.ts
    ...
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:

  1. No cycles. If ordering imports payments and payments imports ordering, 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.
  2. 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:

text
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 together

Catalog's public API — only what ordering actually needs, declared on the consumer side if you want to keep ordering independent of catalog's package:

go
// 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:

  1. Add an import-boundary check (a test using go list or depguard) in report mode to see current violations.
  2. Create internal/catalog and move product handler, service, repository and model into it. Callers update imports; behaviour is unchanged.
  3. Add catalog.Service.Prices and switch ordering from its direct SQL to the PriceLookup interface. Delete ordering's product query.
  4. Move catalog's store and handler under catalog/internal/ so the compiler enforces the boundary.
  5. Repeat for accounts, ordering and payments, one module per PR.
  6. 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 api and implementation modules; 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 with no-restricted-imports or boundary plugins.
  • Keep the dependency graph acyclic, with stable modules at the bottom; avoid util and common dumping 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. 1.In a codebase split into api and implementation modules per feature, which dependency is allowed?

  2. 2.What does an internal directory at shop/internal/ordering/internal/store enforce in Go?

  3. 3.Why is package by layer (handlers/, services/, repositories/, models/) usually worse than package by feature as a codebase grows?

  4. 4.What best describes a modular monolith?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.