Skip to content
full-stack-dev-johncastrosanabriaPublic

About

Banking portfolio application with .NET, Angular and PostgreSQL: accounts, transfers, transaction ledger and administrator reversals. Clean Architecture and JWT authentication.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

Banking API

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/.


What it does

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.

Architecture

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
Loading

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.

Banking.Domain

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).

Banking.Application

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.

Banking.Infrastructure

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.

Banking.Api

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.

Domain model

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
    }
Loading

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.


Security model

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
Loading

Two deliberate decisions worth calling out:

  • Signup never reveals whether an address is registered. POST /api/auth/signup always answers 202 Accepted with 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 → 401 with one message.

Key flows

Login

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
Loading

Transfer between accounts

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
Loading

If Withdraw/Deposit throws, the using transaction disposes without committing — no balance change, no ledger row — and GlobalExceptionHandler turns it into a 409.

Rollback

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
Loading

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.


API reference

🔓 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.

Error mapping

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.


Getting started

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.Api

The API listens on http://localhost:5277.

Configuration

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.

Seeded administrator

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.


Tests

dotnet test

71 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.


Why this shape

  • 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.

About

Banking portfolio application with .NET, Angular and PostgreSQL: accounts, transfers, transaction ledger and administrator reversals. Clean Architecture and JWT authentication.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages