# 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.

Software Engineer roadmap · Architecture · 18 min read · https://levelcode.org/roadmaps/software-engineer/modular-design

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](https://levelcode.org/concepts/coupling-and-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](https://levelcode.org/concepts/package-by-feature) 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](https://levelcode.org/roadmaps/software-engineer/solid) 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](https://levelcode.org/concepts/hyrums-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](https://levelcode.org/concepts/monolith-vs-microservices) covers the trade-off, and [Conway's law](https://levelcode.org/concepts/conways-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.

**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.

## Quiz

1. In a codebase split into api and implementation modules per feature, which dependency is allowed?

   A. featureB:implementation depends on featureA:implementation
   B. featureA:api depends on featureB:implementation
   C. featureB:implementation depends on featureA:api
   D. featureA:api depends on the app module

   **Answer: C.** Implementations may depend on other features' small, stable API modules. Only the app module sees implementations, and wires them together.

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

   A. The package can only be imported by code under shop/internal/ordering
   B. The package cannot be tested
   C. The package's identifiers are all unexported
   D. The package is excluded from builds

   **Answer: A.** The Go toolchain only allows importing an internal package from code rooted at the internal directory's parent, so ordering's storage is invisible to other modules.

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

   A. Go does not allow packages named services
   B. It produces more files
   C. Layered packages cannot contain interfaces
   D. A single feature change touches every layer package, and nothing stops features reaching into each other's internals

   **Answer: D.** Layers have low cohesion — code that changes together is spread out — and everything in a layer is visible to every feature. Feature packages keep related code together and can hide their internals.

4. What best describes a modular monolith?

   A. A monolith with no internal structure
   B. One deployable whose code is split into strictly separated modules that own their data and talk only through public APIs
   C. A set of microservices sharing one database
   D. A monolith deployed to multiple regions

   **Answer: B.** It keeps one deployment and in-process calls while enforcing service-like boundaries, which makes later extraction of a module into a service straightforward.
