A .NET 10 banking API built with Clean Architecture (a.k.a. Onion Architecture): dependencies only point inward, so business rules never depend on frameworks, databases, or HTTP.
It covers the full retail-banking loop — sign up, open accounts, deposit, withdraw, transfer to a confirmed payee, save favorites, read a statement — plus an administrator console with an auditable transaction ledger and rollback.
Banking.Api → entry point / HTTP, JWT auth, controllers
Banking.Application → use cases / orchestration / authorization rules
Banking.Domain → business rules (no dependencies)
Banking.Infrastructure → EF Core, PostgreSQL, ASP.NET Identity, SMTP
Banking.Tests → unit tests (71)
The Angular client that consumes this API lives in banking-web/.
| Capability | Notes |
|---|---|
| Signup / login | ASP.NET Identity for credentials, JWT bearer tokens for sessions (8h). |
| Roles | Exactly two: Admin and User. Admin is seeder-only — no API path can grant it. |
| Accounts | Open, deposit, withdraw, block/activate/close, delete (guarded). |
| Transfers | Same-currency only, atomic, between any two active accounts. |
| Payee lookup | Resolve an account number to the owner's name only — never their email or balance. |
| Favorites | Saved payees, stored by account id so they never go stale. |
| Ledger | Every money movement recorded as a Transaction; per-account statements with direction. |
| Rollback | Admin-only reversal that mirrors the original movement and leaves both rows in the ledger. |
graph TD
Api["Banking.Api<br/>Controllers · JWT · Middleware"]
App["Banking.Application<br/>Services · DTOs · Ports"]
Domain["Banking.Domain<br/>Entities · Enums · Exceptions"]
Infra["Banking.Infrastructure<br/>EF Core · Identity · SMTP"]
Tests["Banking.Tests"]
Api --> App
Api --> Infra
Infra -. implements ports .-> App
App --> Domain
Infra --> Domain
Tests --> App
Tests --> Domain
Tests --> Infra
style Domain fill:#2b6cb0,color:#fff
style App fill:#2f855a,color:#fff
style Infra fill:#c05621,color:#fff
style Api fill:#805ad5,color:#fff
style Tests fill:#718096,color:#fff
Note the dotted arrow: Infrastructure implements interfaces owned by Application (Dependency Inversion). Application never references Infrastructure. Api references Infrastructure only to wire it up in Program.cs.
The innermost layer. Pure business logic, zero external dependencies — except ApplicationUser, which derives from Identity's IdentityUser<Guid> so credentials live in one place.
| File | Responsibility |
|---|---|
Entities/ApplicationUser.cs |
A customer. Identity owns Email/PasswordHash/SecurityStamp; FullName and DocumentId are the bank's own data. |
Entities/Account.cs |
A bank account. Balance has a private set and changes only via Deposit()/Withdraw(), which enforce "must be active" and "amount > 0" / "sufficient funds". |
Entities/Transaction.cs |
One row per money movement. FromAccountId is null for a deposit, ToAccountId null for a withdrawal, both set for a transfer. |
Entities/Favorite.cs |
A saved payee, stored by AccountId rather than a copied account number, so the display always reflects the account's current owner and status. |
Constants/Roles.cs |
Admin / User as constants, so a typo is a compile error rather than a silently failing authorization check. |
Enums/ |
AccountStatus (Active, Blocked, Closed), TransactionType (Deposit, Withdrawal, Transfer, Reversal), TransactionStatus (Completed, Reversed). |
Exceptions/ |
BusinessRuleException → 409, NotFoundException → 404, ForbiddenException → 403, InvalidCredentialsException → 401. |
Key idea: money cannot move except through Account.Deposit/Account.Withdraw. No other layer mutates Balance — the entity is the single source of truth for its own consistency, which is also what makes rollback safe (see below).
Use cases. Orchestrates domain entities, owns the authorization rules, and defines the ports outer layers implement.
| Folder | Responsibility |
|---|---|
Abstractions/ |
Ports: IUserRepository, IAccountRepository, ITransactionRepository, IFavoriteRepository, IUnitOfWork, ICurrentUser, IIdentityService, IJwtTokenGenerator, IEmailSender. |
Services/ |
UserService, AccountService, TransferService, TransactionService, FavoriteService, AuthService, plus AccountNumberGenerator. |
DTOs/ |
Request/response records with validation attributes — the contract exposed to the API, decoupled from entities. |
Mappings/ |
Entity → DTO conversion (ToDto()). |
DependencyInjection.cs |
AddApplication(). |
Key idea — authorization lives here, not only in the controller. Every privileged use case calls a private EnsureAdmin() / EnsureCanAccess(ownerId) guard inside the service. [Authorize] on the controller is the outer layer; the service is the one that actually refuses:
private void EnsureCanAccess(Guid ownerId)
{
if (!currentUser.IsAdmin && ownerId != currentUser.UserId)
throw new ForbiddenException("You can only access your own accounts.");
}That way a new controller, a background job, or a test calling the service directly cannot bypass the rule.
Implements the Application ports. The only layer that knows persistence, Identity, or SMTP exist.
| File | Responsibility |
|---|---|
Persistence/BankingDbContext.cs |
IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid> plus Accounts, Transactions, Favorites. |
Persistence/Configurations/*.cs |
Fluent mapping: unique indexes on account number and document id, Status stored as string, Restrict delete on Account → User — no cascading deletes on financial data. |
Repositories/*.cs |
Implement the Application repository ports. Reads use AsNoTracking(); writes fetch tracked entities. |
UnitOfWork.cs |
SaveChangesAsync and ExecuteInTransactionAsync — rollback happens automatically via using if an exception escapes before commit. |
Identity/IdentityService.cs |
Wraps UserManager/RoleManager behind IIdentityService, keeping Identity types out of Application. |
Identity/DbSeeder.cs |
Idempotently seeds both roles and the single administrator. Self-heals a user row that exists without its role. |
Authentication/JwtTokenGenerator.cs |
Issues HS256 tokens carrying sub, email, name and role. |
Email/SmtpEmailSender.cs |
MailKit sender used by the signup notification. |
Migrations/ |
EF Core migration history; applied automatically at startup. |
Thin HTTP adapters. Controllers translate HTTP ⇄ Application calls and contain no business logic.
| File | Responsibility |
|---|---|
Controllers/ |
AuthController, UsersController, AccountsController, MeController, TransactionsController. |
Security/CurrentUser.cs |
Reads sub / role off the ClaimsPrincipal and exposes them as ICurrentUser — the bridge that lets Application authorize without referencing HTTP. |
Middleware/GlobalExceptionHandler.cs |
Central IExceptionHandler: domain exceptions → ProblemDetails with the right status. |
Program.cs |
Composition root: Identity, JWT bearer, fallback authorization policy, Swagger with a bearer scheme, CORS for http://localhost:4200, migrations + seeding at boot. |
erDiagram
APPLICATION_USER ||--o{ ACCOUNT : owns
APPLICATION_USER ||--o{ FAVORITE : saves
ACCOUNT ||--o{ FAVORITE : "is the payee of"
ACCOUNT ||--o{ TRANSACTION : "sends from"
ACCOUNT ||--o{ TRANSACTION : "receives into"
TRANSACTION ||--o| TRANSACTION : reverses
APPLICATION_USER {
Guid Id PK
string FullName
string DocumentId UK
string Email UK "from IdentityUser"
string PasswordHash "from IdentityUser"
DateTime CreatedAt
}
ACCOUNT {
Guid Id PK
string AccountNumber UK
decimal Balance "private set"
string Currency
AccountStatus Status
Guid UserId FK
DateTime CreatedAt
}
TRANSACTION {
Guid Id PK
TransactionType Type
Guid FromAccountId FK "null for a deposit"
Guid ToAccountId FK "null for a withdrawal"
decimal Amount
string Currency
TransactionStatus Status
Guid ReversalOfTransactionId FK "set on a reversal"
Guid PerformedByUserId
DateTime CreatedAt
}
FAVORITE {
Guid Id PK
Guid OwnerUserId FK
Guid AccountId FK
string Alias "nullable"
DateTime CreatedAt
}
Roles come from the standard Identity tables (AspNetRoles, AspNetUserRoles), which BankingDbContext inherits.
Account.UserId → User.Id is Restrict on delete: a user with accounts cannot cascade-delete them, and UserService.DeleteAsync explicitly blocks deleting a user who still has an account.
Every endpoint requires authentication by default — Program.cs installs a fallback policy, so a newly added controller is locked down unless it opts out with [AllowAnonymous].
flowchart TD
Req["HTTP request"] --> Bearer{"Valid JWT<br/>bearer token?"}
Bearer -- no --> Anon{"Endpoint marked<br/>AllowAnonymous?"}
Anon -- no --> R401["401 Unauthorized"]
Anon -- yes --> Ctrl
Bearer -- yes --> RoleChk{"Controller requires<br/>the Admin role?"}
RoleChk -- "yes, and caller is not Admin" --> R403["403 Forbidden"]
RoleChk -- otherwise --> Ctrl["Controller"]
Ctrl --> Svc["Application service"]
Svc --> Guard{"EnsureAdmin /<br/>EnsureCanAccess"}
Guard -- refuses --> Forb["ForbiddenException → 403"]
Guard -- allows --> Domain["Domain entity<br/>enforces money rules"]
Domain --> Ok["200 / 204"]
Domain -- rule violated --> R409["409 Conflict"]
style Guard fill:#2f855a,color:#fff
style Domain fill:#2b6cb0,color:#fff
style R401 fill:#c53030,color:#fff
style R403 fill:#c53030,color:#fff
style R409 fill:#c05621,color:#fff
Two deliberate decisions worth calling out:
- Signup never reveals whether an address is registered.
POST /api/auth/signupalways answers202 Acceptedwith the same message and no token, whether the address was new or already taken. The already-registered path burns the same PBKDF2 cost so response time isn't an oracle either, and mails the real owner that someone tried. Issuing a token for an existing address would be account takeover. - Login failures are indistinguishable. Wrong email and wrong password both raise
InvalidCredentialsException→401with one message.
sequenceDiagram
participant C as Client
participant Api as AuthController
participant Svc as AuthService
participant Id as IdentityService
participant Jwt as JwtTokenGenerator
C->>Api: POST /api/auth/login
Api->>Svc: LoginAsync(request)
Svc->>Id: FindByEmailAsync + CheckPasswordAsync
alt wrong email or password
Id-->>Svc: no match
Svc-->>C: 401 "Invalid email or password."
else valid
Id-->>Svc: user + roles
Svc->>Jwt: Generate(user, roles)
Jwt-->>Svc: HS256 token, 8h expiry
Svc-->>C: 200 { token, expiresAt, user }
end
Shows how one request crosses all four layers, and where the transaction boundary sits.
sequenceDiagram
participant C as Client
participant Api as AccountsController
participant Svc as TransferService
participant Acc as Account (Domain)
participant UoW as UnitOfWork
participant DB as PostgreSQL
C->>Api: POST /api/accounts/transfer
Api->>Svc: TransferAsync(request)
Svc->>Svc: caller owns the source account?
Svc->>UoW: ExecuteInTransactionAsync
UoW->>DB: BEGIN
Svc->>DB: load source and destination
Svc->>Svc: same currency?
Svc->>Acc: from.Withdraw(amount)
Note over Acc: throws if inactive<br/>or insufficient funds
Svc->>Acc: to.Deposit(amount)
Svc->>DB: insert Transaction row
UoW->>DB: SaveChanges + COMMIT
Api-->>C: 204 No Content
If Withdraw/Deposit throws, the using transaction disposes without committing — no balance change, no ledger row — and GlobalExceptionHandler turns it into a 409.
An admin reversal mirrors the original movement rather than deleting it, so the ledger stays append-only and auditable.
flowchart TD
Start["POST /api/transactions/:id/rollback"] --> Admin{"Caller is Admin?"}
Admin -- no --> F403["403 Forbidden"]
Admin -- yes --> Load["Load the original inside a transaction"]
Load --> IsRev{"Is it itself<br/>a Reversal?"}
IsRev -- yes --> C1["409 · a reversal cannot be reversed"]
IsRev -- no --> Done{"Already Reversed?"}
Done -- yes --> C2["409 · already reversed"]
Done -- no --> Take["ToAccount.Withdraw(amount)"]
Take -- "receiver already spent it" --> C3["409 · insufficient funds<br/>nothing is written"]
Take -- ok --> Give["FromAccount.Deposit(amount)"]
Give --> Write["Insert Reversal row<br/>flip original to Reversed"]
Write --> Commit["COMMIT → 200"]
style Take fill:#2b6cb0,color:#fff
style Commit fill:#2f855a,color:#fff
style C1 fill:#c05621,color:#fff
style C2 fill:#c05621,color:#fff
style C3 fill:#c05621,color:#fff
style F403 fill:#c53030,color:#fff
Taking the money out first is the important detail. If the receiving account has already spent the funds, Withdraw throws before anything has been credited anywhere, and the whole transaction rolls back — a refused rollback leaves no balance change and no ledger row behind.
🔓 anonymous · 👤 any authenticated user · 🛡️ Admin only
| Method | Route | Access | Purpose |
|---|---|---|---|
| POST | /api/auth/signup |
🔓 | Register. Always 202 with a neutral message, never a token. |
| POST | /api/auth/login |
🔓 | Exchange credentials for a JWT. |
| GET | /api/me |
👤 | Own profile. |
| GET | /api/me/accounts |
👤 | Own accounts. |
| GET | /api/me/favorites |
👤 | Saved payees. |
| POST | /api/me/favorites |
👤 | Save a payee by account number. |
| DELETE | /api/me/favorites/{id} |
👤 | Remove a payee. |
| GET | /api/accounts/lookup?accountNumber= |
👤 | Resolve a number to the owner's name only. |
| GET | /api/accounts/{id} |
👤 | Account detail — own only, unless Admin. |
| GET | /api/accounts/{id}/transactions |
👤 | Statement with In/Out direction. |
| POST | /api/accounts |
👤 | Open an account. A non-admin always gets themselves as owner. |
| POST | /api/accounts/{id}/deposit |
👤 | Deposit. |
| POST | /api/accounts/{id}/withdraw |
👤 | Withdraw. |
| POST | /api/accounts/transfer |
👤 | Transfer to an account number. |
| GET | /api/accounts |
🛡️ | Search all accounts by owner/number/status/currency. |
| PUT | /api/accounts/{id}/status |
🛡️ | Block / activate / close. |
| DELETE | /api/accounts/{id} |
🛡️ | Delete — refused if it holds a balance or has history. |
| GET | /api/users · /api/users/{id} |
🛡️ | List / read customers. |
| POST · PUT · DELETE | /api/users… |
🛡️ | Manage customers. Deleting yourself is refused. |
| GET | /api/users/{id}/accounts |
🛡️ | A customer's accounts. |
| GET | /api/transactions |
🛡️ | Ledger, filtered by account/type/status/date range. |
| POST | /api/transactions/{id}/rollback |
🛡️ | Reverse a movement. |
Swagger UI is available at /swagger in Development, with a Authorize button for pasting a bearer token.
| Exception | Status | Example |
|---|---|---|
NotFoundException |
404 | Account CR-000000000000 was not found. |
BusinessRuleException |
409 | Insufficient funds in account CR-… |
ForbiddenException |
403 | You can only access your own accounts. |
InvalidCredentialsException |
401 | Invalid email or password. |
| anything else | 500 | An unexpected error occurred. (details logged, never returned) |
All responses are ProblemDetails JSON; the client reads title.
Prerequisites: .NET 10 SDK, PostgreSQL 14+, and an SMTP endpoint (any local catcher such as MailHog/Mailpit on port 1025 works for development).
# 1. PostgreSQL — matches the default connection string
# Host=localhost;Port=5432;Database=bankdb;Username=postgres;Password=postgres
docker run -d --name bankdb -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16
# 2. Optional: a local mail catcher for signup notifications
docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit
# 3. Run — migrations and seeding happen automatically at boot
dotnet run --project src/Banking.ApiThe API listens on http://localhost:5277.
appsettings.Development.json ships working development values. For any other environment, set at least:
| Key | Environment variable | Notes |
|---|---|---|
ConnectionStrings:Default |
ConnectionStrings__Default |
PostgreSQL connection string. |
Jwt:Key |
Jwt__Key |
≥ 32 bytes — HS256 needs a 256-bit key. Startup fails fast if it is missing or short. |
Jwt:Issuer / Jwt:Audience / Jwt:ExpiryHours |
— | Default to banking-api / banking-web / 8. |
Smtp:Host / Smtp:FromAddress |
Smtp__Host / Smtp__FromAddress |
Also validated at startup rather than at the first signup. |
DbSeeder creates both roles and one administrator on every boot, idempotently:
admin@banking.com / Admin123*
This is the only place the Admin role is ever assigned — signup always produces a User. Change the password before deploying anywhere real.
dotnet test71 unit tests covering the domain entity rules, every Application service (against Moq fakes of the ports — no database needed), the JWT generator and the seeder.
- Domain has no dependencies — testable with zero mocks, and unaffected when a database or framework changes.
- Application depends only on Domain and defines the ports it needs, so services are tested against fakes. It also owns authorization, which keeps the rule in one place regardless of who calls.
- Infrastructure depends on Application (to implement its ports) and Domain (to persist entities) — swapping PostgreSQL, Identity or the mail transport touches only this layer.
- Api depends on everything but contains no business logic — controllers are a translation layer between HTTP and use cases.