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
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:
# .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.cataloguelint-imports # fails the build on a violationThis 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
sharedand a feature — someone needed one function and moved it “down”.sharedshould 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 |
# 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/andapi/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.