Backend / Architecture & design / 02_feature_sliced_structure.md

Feature-based structure

Updated 5 interview angles 3 min read source
On this page5
  1. The two shapes
  2. The rule that makes it work
  3. Talking to another feature
  4. How it relates to DDD
  5. Interview angle

Feature-based structure

Organise code by what it does, not by what kind of thing it is. The frontend name for the strict version is Feature-Sliced Design; the backend equivalent is modular monolith or package-by-feature. The argument is the same either way, and so is the thing that makes it fail.

The two shapes

text
by layer                     by feature
src/                         src/
├── models/                  ├── billing/
├── services/                │   ├── models.py
├── repositories/            │   ├── service.py
└── api/                     │   └── api.py
                             ├── orders/
                             └── shared/

A change to billing touches four directories in the left shape and one in the right. That is the whole argument, and it only bites above a certain size — layer-based is perfectly fine for a small service and becomes a navigation cost as the codebase grows.

The frontend variant adds a fixed layer order — app, pages, widgets, features, entities, shared — where each layer may only import from the ones below it. The backend variant usually has fewer layers and the same rule.

The rule that makes it work

Features may not import each other’s internals. Without that enforced, feature folders are layer folders with extra steps: everything imports everything, and you have gained directory names.

Dependencies point one way — features may use shared, shared may never use a feature:

ini
# .importlinter
[importlinter]
root_package = src

[importlinter:contract:1]
name = Features are independent
type = independence
modules =
    src.billing
    src.orders
    src.catalogue

[importlinter:contract:2]
name = Shared knows nothing
type = forbidden
source_modules = src.shared
forbidden_modules = src.billing, src.orders, src.catalogue
bash
lint-imports          # fails the build on a violation

This is the part that gets skipped, and it is the only part that matters. A convention nobody checks decays in one sprint; a CI check does not.

Gotcha: the first sign the boundary has eroded is a cycle between shared and a feature — someone needed one function and moved it “down”. shared should hold code with no domain knowledge at all; anything that knows what an invoice is belongs in the feature.

Talking to another feature

Once features cannot import each other, they still need to interact. The options, in increasing decoupling:

Mechanism Coupling
A published interface module compile-time, explicit
An event on a bus runtime, one-way
Nothing — merge them they were one feature
python
# billing/public.py — the only module orders may import.
def charge(order_id: str, amount: Money) -> ChargeResult: ...

Everything else in billing/ is private by contract, which the import-linter rule enforces. That published module is where the seam would be if the feature ever became a service.

How it relates to DDD

A feature slice often corresponds to a bounded context, and when it does the structure reinforces the domain boundary instead of fighting it. That also makes the extraction question answerable: a feature with a narrow published interface and no shared tables is a service you could extract — see Monolith vs microservices.

Interview angle 5

  • “Layer-based or feature-based structure?” - feature-based scales better past a certain size: a change lives in one directory instead of touching models/, services/ and api/ separately. Layer-based is fine for small services and becomes a navigation cost as the codebase grows.
  • “What actually makes it work?” - enforced boundaries. Without a rule that features may not import each other’s internals, feature folders become layer folders with extra steps. Enforce it with import-linter or an architecture test in CI, not with a convention.
  • “How do features share code?” - a shared layer for genuinely cross-cutting concerns, with the dependency direction one-way: features may import shared, shared may never import a feature. A cycle there is the first sign the boundary has eroded.
  • “How do two features talk to each other?” - through a published interface module, or an event. Everything else in the feature stays private by contract, and that published module is the seam if it ever becomes a service.
  • “How does this relate to DDD?” - a feature slice often corresponds to a bounded context. If it does, the structure reinforces the domain boundary rather than fighting it, and it makes “could we extract this?” a question with an answer.