The Β«shortΒ» version of "why" and "how" behind Feature-Action Architecture.
- Why FAA Exists
- The 5 Rules
- Feature Anatomy
- Entity Anatomy
- Where to Put Code
- Anti-Patterns
- When to Break the Rules
- Language Examples
We've all been there. You open a project and see:
services/UserService.ts β 400 lines, handles auth, profiles, settings
services/OrderService.ts β 600 lines, handles cart, payment, shipping
repositories/UserRepository β half the methods just call .findOne()
The boundaries between "service" and "repository" are blurry. One dev puts business logic in a service, another puts it in a repository. A third creates a "helper" because they're not sure where it belongs. The codebase becomes a maze.
FAA gives you clear rules. Not suggestions β rules. So that every developer (and every AI agent) knows exactly where code goes.
A feature implements a business use case that a user can trigger. Internal mechanisms β cron jobs, queue workers, background processors β are not features. They live in shared/infra/ and are wired from app/.
Don't organize by technical role. Do organize by business domain.
β Bad β
Good
controllers/ features/
authController.ts auth/
userController.ts api/handler.ts
services/ login.action.ts
authService.ts user-profile/
userService.ts api/handler.ts
repositories/ get-profile.action.ts
authRepo.ts
userRepo.ts
Want to understand the login flow? In FAA, open
features/auth/β everything is right there.
A Service is a bag of loosely related methods. An Action is a single function that does one thing.
β UserService.register() β
features/auth/register.action.ts
β UserService.getProfile() β
features/user-profile/get-profile.action.ts
β UserService.updateSettings() β
features/settings/update.action.ts
Actions are created through factory functions that accept dependencies (for example, via typed-inject):
export const createRegisterAction = (deps: { userDal, mailer }) =>
async (data: RegisterInput) => {
const user = await deps.userDal.create(data);
await deps.mailer.sendWelcome(user.email);
return user;
};π typed-inject wiring (minimal)
import { createInjector } from "typed-inject";
const container = createInjector()
.provideFactory("userDal", createUserDal)
.provideFactory("mailer", createMailer)
.provideFactory("registerAction", createRegisterAction);
const register = container.resolve("registerAction");Tip
An Action is an orchestrator. It calls Entity DALs, applies business rules, and returns a result. That's it.
App β Features β Entities β Shared
Every arrow points down. No exceptions1.
- Feature can import from Entity and Shared
- Entity can import from Shared
- Feature cannot import another Feature
- Entity cannot import another Entity
- Nobody imports from App (except the entry point)
Don't dump all queries into a global repositories/ folder. Put them where they're used.
| Query type | Where it lives | Example |
|---|---|---|
| Generic CRUD | entities/{name}/dal.ts |
findById, create, update |
| Reusable domain logic | entities/{name}/lib/ |
getOrCreate, updatePrivacy |
| Feature-specific queries | features/{name}/db/ |
Complex aggregations, joins, reports |
Note
This is the key insight: a leaderboard aggregation pipeline has nothing to do with UserEntity. It belongs in features/leaderboard/db/pipelines.ts.
No hidden globals. No import db from '../../../shared/db' deep in a service. Dependencies come through factory injection (for example, via typed-inject):
// β
Dependencies are explicit and visible
export const createLoginAction = (deps: { userDal, config }) =>
async (data) => { /* ... */ };
// β Hidden dependency on global singleton
import { db } from "../../../shared/db";
export const login = async (data) => {
const user = await db.users.findOne(/*...*/);
};Tip
Explicit deps = easy to test (just pass mocks), easy to reason about (read the type signature), easy to refactor (find all usages).
Every feature follows the same structure:
features/{feature-name}/
βββ api/
β βββ handler.ts # HTTP transport layer
βββ db/
β βββ pipelines.ts # Complex DB queries (aggregations, joins)
β βββ cache.ts # Caching logic (if needed)
βββ lib/
β βββ helpers.ts # Feature-local utilities
βββ {name}.action.ts # π THE business logic
βββ types.ts # Types and interfaces
βββ index.ts # Public API (only this is importable!)
Important
index.ts is the only entry point. Everything else is internal to the feature. External code imports from features/auth/index.ts, never from features/auth/lib/helpers.ts directly.
Keep index.ts lean β only re-export what the App layer actually needs (usually the action factory and the handler factory). Don't dump every internal helper into the barrel.
Other languages: In Java / C# / Go the same idea is enforced by access modifiers (package-private, internal, unexported). No barrel file needed β just don't make internal classes/methods public.
| File | Responsibility | Thickness |
|---|---|---|
api/handler.ts |
Parse request β call action β format response | Thin |
*.action.ts |
Orchestrate entity calls, apply business rules | Main logic |
db/pipelines.ts |
Feature-specific queries (aggregation, complex joins) | Data |
lib/ |
Calculations, transformations, validation | Helper |
types.ts |
Request/response types, internal interfaces | Types |
index.ts |
Re-export public API | Barrel |
Entities own domain data and provide reusable operations:
entities/{entity-name}/
βββ model.ts # DB schema definition
βββ dal.ts # Data Access Layer (CRUD)
βββ cache.ts # Cache operations (optional)
βββ lib/
β βββ queries.ts # Complex read operations
β βββ commands.ts # Complex write operations
β βββ helpers.ts # Pure domain functions
βββ types.ts # TypeScript types
dal.ts |
lib/ |
|---|---|
| Basic CRUD only | Rich domain logic |
findById, create, update, delete |
getOrCreate, findWithRelations |
| Zero business logic | Can contain business rules |
| Every entity has it | Only if needed |
Borderline cases:
| Method | Where? | Why |
|---|---|---|
findAllActive() |
dal.ts |
It's a simple filter β find({ active: true }). Still CRUD. |
findWithStats() |
lib/queries.ts |
Aggregation / join β goes beyond a basic .find() call. |
deactivateExpired() |
lib/commands.ts |
Contains domain rule (what counts as "expired"). |
Tip
If you're debating "is this CRUD or business logic?" β if it's more than a single Model.findOne() / find() with a trivial filter, it probably belongs in lib/.
When in doubt, follow this:
graph TD
START["I have new code"] --> Q1{"Is it a pure utility?<br/>(datetime, encoding, math)"}
Q1 -->|Yes| SHARED["π shared/lib/"]
Q1 -->|No| Q2{"Is it infrastructure?<br/>(DB driver, logger, config)"}
Q2 -->|Yes| INFRA["π shared/infra/"]
Q2 -->|No| Q3{"Is it generic CRUD<br/>for a single entity?"}
Q3 -->|Yes| DAL["π entities/{name}/dal.ts"]
Q3 -->|No| Q4{"Is it reusable domain logic<br/>for a single entity?"}
Q4 -->|Yes| LIB["π entities/{name}/lib/"]
Q4 -->|No| Q5{"Is it a complex query<br/>needed by one feature?"}
Q5 -->|Yes| FDB["π features/{name}/db/"]
Q5 -->|No| ACTION["π features/{name}/*.action.ts"]
style SHARED fill:#fff3e0,stroke:#f57c00
style INFRA fill:#fff3e0,stroke:#f57c00
style DAL fill:#e8f5e9,stroke:#388e3c
style LIB fill:#e8f5e9,stroke:#388e3c
style FDB fill:#f3e5f5,stroke:#7b1fa2
style ACTION fill:#f3e5f5,stroke:#7b1fa2
Tip
Will this logic be used by 2+ features? β Entity (entities/{name}/lib/).
Only one feature needs it? β Keep it in that feature (features/{name}/db/ or features/{name}/lib/).
Quick cheat sheet:
| I need to... | Put it in... |
|---|---|
| Find a user by ID | entities/user/dal.ts |
| Find-or-create a user | entities/user/lib/queries.ts |
| Normalize a username | entities/user/lib/helpers.ts |
| Get top-10 for a race | features/leaderboard/race/db/pipelines.ts |
| Generate a daily size, cache it, check achievements | features/cock-size/generate.action.ts |
| Format a date in Moscow time | shared/lib/datetime/ |
| Connect to MongoDB | shared/infra/persistence/ |
Handle POST /api/auth/login |
features/auth/api/handler.ts |
// BAD: one action doing everything
export const createEverythingAction = (deps) =>
async (userId) => {
// 50 lines of auth logic
// 50 lines of profile logic
// 50 lines of notification logic
};Fix: split into loginAction, getProfileAction, notifyAction.
// BAD: horizontal dependency
import { getProfile } from "../user-profile";
export const createDashboardAction = () =>
async () => {
const profile = await getProfile(); // β
};Fix: move shared logic to an Entity or shared/lib/. Example: if auth and notifications both need to send email β shared/lib/mailer, not one feature importing the other.
// BAD: dal.ts shouldn't calculate levels
export const createUserDal = () => ({
levelUp: async (userId: string) => {
const user = await UserModel.findById(userId);
const newLevel = Math.floor(user.exp / 100); // β business logic
return UserModel.updateOne({ _id: userId }, { level: newLevel });
},
});Fix: put level calculation in entities/user/lib/ or in the feature action.
// BAD: a global pipeline that only leaderboard uses
// shared/queries/top-players-pipeline.ts β βFix: features/leaderboard/db/pipelines.ts β keep it where it's used.
FAA is practical, not dogmatic. Here are acceptable compromises:
| Situation | Compromise | Condition |
|---|---|---|
| Two features need the same helper | Keep it in one feature, import from another | Temporary. Plan to extract to Entity/Shared. |
| A formula is used in 3+ features | Put it in shared/lib/ |
Even if it looks "business-y" |
A tiny feature has no db/ |
Skip the folder | Not every feature needs DB queries |
| Entity needs data from another entity | Do the join in the Feature action | Entity stays isolated, Feature orchestrates |
Caution
Breaking a rule is fine if you acknowledge it and have a plan to fix it later. Silent violations compound into spaghetti.
See FAA in action across different stacks:
| Stack | Example |
|---|---|
| TypeScript + Bun | examples/ts-bun.md |
| Kotlin + Spring Boot | examples/kotlin-springboot.md |
| Go + Gin + uber-fx | examples/golang-gin.md |
| Python + Django | examples/python-django.md |
| C# + ASP.NET Core | examples/csharp-asp.md |
| Java + Spring Boot | examples/java-springboot.md |
| PHP + Laravel | examples/php-laravel.md |
| F# + Giraffe | examples/fsharp-giraffe.md |
| Rust + Axum | examples/rust-axum.md |
Footnotes
-
Well, there are pragmatic exceptions. See When to Break the Rules. β©