Slice your backend into features. Replace services with actions. Keep it clean.
FAA is a backend adaptation of Feature-Sliced Design β an architecture where code is organized by business domain (vertical slices), and business logic lives in isolated action functions instead of monolithic service classes.
It's not tied to any language or paradigm. Use FP, OOP, or whatever works for you. All examples here are in TypeScript + functional style, but the ideas are universal.
- π Feature-Action Architecture (FAA)
Traditional backend architecture gives you horizontal layers:
src/
βββ controllers/ # Thin wrappers that call services
βββ services/ # god objects that do everything
βββ repositories/ # Data access, often duplicating service logic
βββ models/ # Schemas, far away from the code that uses them
As the project grows:
UserServicebecomes a 500-line monster handling auth, profiles, settings, notifications...- Repositories duplicate service logic or become pass-through wrappers
- Adding a feature means touching 4+ directories
- Nobody knows whether a query belongs in a service or repository
Sound familiar?
FAA flips the traditional approach:
| Traditional | FAA |
|---|---|
| Organize by technical role (controllers, services...) | Organize by business domain (auth, leaderboard...) |
UserService class with 20 methods |
loginAction, getProfileAction β one function, one job |
| Repositories with ambiguous boundaries | Data access lives where it's used |
| Implicit dependencies via imports | Explicit dependencies via factories + DI |
| # | Principle | TL;DR |
|---|---|---|
| 1 | π Slice, don't layer | No global services/, controllers/, repositories/. Organize by feature. |
| 2 | β‘ Actions over Services | No UserService class. Write loginAction β one function, one job. |
| 3 | β¬οΈ Strict downward flow | App β Features β Entities β Shared. Never up. Never sideways. |
| 4 | π Localize data access | Generic CRUD β Entities. Complex queries β the Feature that needs them. |
| 5 | π Explicit dependencies | Factory functions + DI. No hidden globals. |
graph TD
APP["ποΈ App<br/>Assembly Β· DI Β· Routing"] --> FEATURES
FEATURES["β‘ Features<br/>Business Scenarios"] --> ENTITIES
ENTITIES["π¦ Entities<br/>Domain Data Β· CRUD"] --> SHARED
SHARED["π§ Shared<br/>Infra Β· Utilities"]
style APP fill:#e1f5fe,stroke:#0288d1
style FEATURES fill:#f3e5f5,stroke:#7b1fa2
style ENTITIES fill:#e8f5e9,stroke:#388e3c
style SHARED fill:#fff3e0,stroke:#f57c00
| Layer | What it does | What it contains |
|---|---|---|
| ποΈ App | Assembles everything | Server init, DI container, router, middleware wiring (auth guards, rate limiting, etc.) |
| β‘ Features | Implements use cases | Actions, HTTP handlers, feature-specific queries |
| π¦ Entities | Owns domain data | DB models, CRUD (DAL), reusable domain logic |
| π§ Shared | Provides tools | Logger, DB drivers, config, HTTP utils, pure helpers, reusable middleware |
Important
Each layer can only import from layers below it. Never up, never sideways.
src/
βββ app/ # ποΈ Assembly
β βββ container.ts # DI wiring
β βββ routes.ts # Registers routes from features
β βββ server.ts # Server init & lifecycle
β
βββ features/ # β‘ Business Scenarios
β βββ auth/
β β βββ api/handler.ts # HTTP layer
β β βββ login.action.ts # Business logic (THE action)
β β βββ lib/ # Feature-local helpers
β β βββ types.ts
β β βββ index.ts # Public API
β β
β βββ leaderboard/
β β βββ race/ # Sub-feature (independent!)
β β β βββ api/handler.ts
β β β βββ db/pipelines.ts # Feature-specific queries
β β β βββ get-race.action.ts
β β β βββ index.ts
β β βββ ladder/
β β βββ ...
β βββ ...
β
βββ entities/ # π¦ Domain Data
β βββ user/
β β βββ model.ts # DB schema
β β βββ dal.ts # Generic CRUD
β β βββ lib/ # Reusable domain logic
β β β βββ queries.ts # Complex reads (getOrCreate)
β β β βββ commands.ts # Complex writes (updatePrivacy)
β β β βββ helpers.ts # Pure functions (normalizeName)
β β βββ types.ts
β βββ ...
β
βββ shared/ # π§ Infrastructure
βββ api/ # HTTP primitives (errors, responses, middleware)
βββ lib/ # Pure functions (datetime, encoding)
βββ infra/ # Drivers (DB, logger, config)
Tip
Feature groups like leaderboard/ are just folders for organization. Each subfolder (race/, ladder/) is an independent feature β no cross-imports allowed.
sequenceDiagram
participant C as Client
participant H as Handler
participant A as Action
participant E as Entity DAL
participant DB as Database
C->>H: POST /api/auth/login
H->>A: loginAction(data)
A->>E: userDal.findById(id)
E->>DB: SELECT ...
DB-->>E: User row
E-->>A: User object
A-->>H: { token, user }
H-->>C: 200 { data: { token, user } }
π¦ Entity β User DAL
// entities/user/dal.ts
import { UserModel } from "./model";
export const createUserDal = () => ({
findById: (id: number) =>
UserModel.findOne({ user_id: id }).lean(),
create: (userId: number, username?: string) =>
UserModel.create({ user_id: userId, username }),
});β‘ Feature β Login Action
// features/auth/login.action.ts
type Deps = {
userDal: ReturnType<typeof createUserDal>;
config: AppConfig;
};
export const createLoginAction = (deps: Deps) =>
async (telegramData: TelegramAuth) => {
let user = await deps.userDal.findById(telegramData.id);
if (!user) {
user = await deps.userDal.create(telegramData.id, telegramData.username);
}
const token = signToken(user, deps.config.secret);
return { token, user };
};
createLoginAction.inject = ["userDal", "config"] as const;π Feature β HTTP Handler
// features/auth/api/handler.ts
export const createLoginHandler = (login: ReturnType<typeof createLoginAction>) =>
async (req: Request) => {
const body = await req.json();
const result = await login(body);
return Response.json({ data: result });
};
createLoginHandler.inject = ["loginAction"] as const;ποΈ App β Wiring it all together (for example, typed-inject)
// app/container.ts
import { createInjector } from "typed-inject";
export const createContainer = () => {
const config = loadConfig();
return createInjector()
.provideValue("config", config)
.provideFactory("userDal", createUserDal)
.provideFactory("loginAction", createLoginAction)
.provideFactory("loginHandler", createLoginHandler);
};
const loginHandler = createContainer().resolve("loginHandler");graph LR
F1["Feature A"] -.-x|"β"| F2["Feature B"]
E1["Entity A"] -.-x|"β"| E2["Entity B"]
FE["Feature"] -->|"β
"| EN["Entity"]
EN2["Entity"] -.-x|"β"| FE2["Feature"]
style F1 fill:#ffcdd2,stroke:#c62828
style F2 fill:#ffcdd2,stroke:#c62828
style E1 fill:#ffcdd2,stroke:#c62828
style E2 fill:#ffcdd2,stroke:#c62828
style FE fill:#c8e6c9,stroke:#2e7d32
style EN fill:#c8e6c9,stroke:#2e7d32
| Direction | Verdict | Example |
|---|---|---|
| Feature β Entity | β Allowed | login.action.ts imports userDal |
| Feature β Shared | β Allowed | Action imports datetime utility |
| Feature β Feature | β Forbidden | Everything: code, types, errors. Push shared logic down to Entity |
| Entity β Entity | β Forbidden | Entities are isolated |
| Entity β Feature | β Forbidden | Never import upward |
| Shared β anything above | β Forbidden | Shared is the foundation |
Warning
If two features need the same logic β don't import horizontally. Move the shared logic down to an Entity or Shared layer.
You have two ways to attach FAA documentation to your repo (for humans and AI agents):
Add a link in your project prompt/README (https://github.com/MairwunNx/Feature-Action-Architecture/blob/master/MANIFEST.md).
This is quick, but not always effective β many LLM agents fetch links partially or ignore them depending on their tooling. That can lead to an incomplete picture.
This makes the docs local to the repo, so agents can always read them.
Git submodule declaration (example):
# .gitmodules
[submodule "Feature-Action-Architecture"]
path = Feature-Action-Architecture
url = https://github.com/MairwunNx/Feature-Action-Architecture.git
or
git submodule add https://github.com/MairwunNx/Feature-Action-Architecture.git Feature-Action-ArchitectureProject prompt snippet (you can paste into your AI/project prompt):
### Project architecture
All FAA (Feature-Action Architecture) docs live in `./Feature-Action-Architecture`.
Use the example closest to our stack.
Stack: <your stack here> # example: TS & Bun & typed-inject
Example: `Feature-Action-Architecture/examples/ts-bun.md` # replace with your file
AI notes: `Feature-Action-Architecture/AI.md`
Architecture Manifest: `Feature-Action-Architecture/MANIFEST.md`
| Document | What's inside |
|---|---|
| π MANIFEST.md | The philosophy, the "why", decision guide |
| π€ AI.md | Rules & patterns for AI/LLM agents working with FAA |
Full working examples with project structure, DI wiring, and code snippets:
| 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 |
Note
FAA is language-agnostic and paradigm-agnostic. FP, OOP, whatever β the principles apply. Examples here use TypeScript + functional style because that's what we like π