# N-Tier Architecture — Developer Onboarding

> **Epic:** WI-2081 — N-Tier Architecture Refactor
> **Status:** Phases 0–2 and 5 complete; Phases 3–4 in progress. `ALPAMobile.Presentation` is
> an RCL and is **live** — 54 Blazor pages, the shared component library and 59 ViewModels run
> from it. Remaining head debt is listed under *Migration State*.
> **Last verified against the code:** 2026-08-03 (counts below are measured, not aspirational)
> **Enforced by:** `UnitTest/Architecture/` — `LayerDependencyRulesTests.cs` (NetArchTest) and
> `RazorComponentResolutionTests.cs` (source scan), both CI-gated

---

## TLDR

Five projects, one rule: **dependencies only flow inward.**

```text
ALPAMobile (MAUI host)      ← composition root, XAML pages, natively-bound Blazor pages
ALPAMobile.Presentation     ← Blazor pages, components, ViewModels   [live since WI-2087]
ALPAMobile.Infrastructure   ← LiteDB, HTTP, MSAL, Sentry
ALPAMobile.Application      ← interfaces + orchestration
ALPAMobile.Domain           ← pure business types, no deps
```

If you are adding **business logic** → `Application`.
If you are adding **a model or rule** → `Domain`.
If you are adding **a database call, API call, or platform feature** → `Infrastructure`.
If you are adding **a Blazor page, component, navigation policy, or component ViewModel** →
`ALPAMobile.Presentation` (an RCL since 2026-07-15, WI-2254/D61). **Which folder inside it is a
second decision — see *Two kinds of component* below.** Naming the project and stopping there is
what put a statically implemented page's row into the feed-hydration library.

A Blazor page belongs in the head project **only** if it binds something the head owns — a
native page type, or a `*QueriesRouter` whose port does not exist yet. "It was written there
first" is not a reason: check for an `IScaffold*Queries` port before leaving one behind.

**Everything a Presentation file references must also live in Presentation** (or in
Application/Domain). Presentation cannot reference the head project. For a C# type the compiler
enforces this; **for a Razor component tag nothing does** — see the pitfall below.

The architecture test suite runs on every build.
If you violate a boundary, CI fails with the offending type names.

---

## Two kinds of component

`ALPAMobile.Presentation` holds two families that look alike and are not interchangeable.
Putting one in the other's folder compiles, ships, and quietly misstates what the component is.

| | `Components/Library/` | `Components/<Feature>/` |
| --- | --- | --- |
| What it is | The RCL component library | Reusable components for a statically implemented area |
| Who renders it | `ComponentView`, from server-supplied content | A page, directly in its markup |
| Driven by | A `*ViewModel` the content→component mapper produces | Whatever the page passes it |
| Examples | `CardSmall`, `CardHero`, `Carousel`, `ButtonCard` | `Components/FTDT/`, `Components/Notifications/` |
| Namespace | `ALPADocs.Components.Library` | `ALPADocs.Components.<Feature>` |

**The test — can the server ask for this component by name, in a feed payload, without a code
change?**

- **Yes** → `Components/Library/`. It is part of the MEC composition / UI-hydration contract, and
  `ComponentView` needs a `case` for its ViewModel. Adding one here without that case gives you a
  component the mapper can never produce.
- **No** → a feature folder. It is a normal component that a page renders because that page's
  design calls for it, and the hydration surface should not know it exists.

Getting it backwards is not a compile error and not a visual one, which is why it needs a rule:
a static row sitting in `Library/` reads to the next person as part of the hydration contract, and
they will look for the mapper case that explains it. `CommsNotificationCard` was filed there in
AB#2286 for exactly that reason and moved to `Components/Notifications/`.

The split does **not** mean static areas skip components — see the markup-duplication rule in
[Engineering Conventions](ENGINEERING-CONVENTIONS.md). A second surface needing the same visual
means a component either way; this section only decides where it goes.

---

## Layer Map

```text
┌─────────────────────────────────────────────────────────┐
│  ALPAMobile  (MAUI host / composition root)             │
│  MauiProgram.cs · XAML pages · XAML ViewModels          │
│  DataManager/RestService · BusinessLogic/ (legacy)      │
│  Targets: net10.0-ios, net10.0-android                  │
├─────────────────────────────────────────────────────────┤
│  ALPAMobile.Presentation   (RCL, live)                  │
│  Blazor pages · component library · component VMs       │
│  Components/Navigation/ — routing + deep-link policy    │
│  Targets: net10.0-ios, net10.0-android                  │
├─────────────────────────────────────────────────────────┤
│  ALPAMobile.Infrastructure                              │
│  LiteDB stores · HTTP/REST · MSAL · Sentry · Caching   │
│  Targets: net10.0                                       │
├─────────────────────────────────────────────────────────┤
│  ALPAMobile.Application                                 │
│  Abstractions (interfaces) · Orchestration services    │
│  Targets: net10.0  (no MAUI, no LiteDB, no Newtonsoft) │
├─────────────────────────────────────────────────────────┤
│  ALPAMobile.Domain                                      │
│  POCOs · value objects · domain rules · enums           │
│  Targets: net10.0  (no external dependencies)           │
└─────────────────────────────────────────────────────────┘
```

**Allowed references:**

| Layer | May reference |
| --- | --- |
| Domain | Nothing outside the BCL |
| Application | Domain only |
| Infrastructure | Application + Domain |
| Presentation | Application + Domain |
| Head (MAUI host) | All layers — this is the composition root |

**Never allowed:**

- Domain → anything outside BCL
- Application → Infrastructure, Presentation, or MAUI
- Infrastructure → Presentation or head project
- Presentation → Infrastructure directly (goes through Application interfaces)

---

## Domain Layer (`ALPAMobile.Domain`)

**What lives here:** pure C# types with no framework dependencies.

```text
ALPAMobile.Domain/
├── Common/          # Result<T>, Error — railway-oriented return types
├── Data/Models/     # POCOs (DutyPeriod, Flight, etc.) + domain records
├── Enums/           # Shared enumerations
├── Helpers/         # BindableBase (owned here; used by head-project subclasses)
├── Interfaces/      # Domain-level contracts (IDutyPeriod_CAN, IDutyPeriod_USA, …)
└── Fonts/           # Font constants
```

**Hard rules:**

- No `using Microsoft.Maui.*`
- No `using LiteDB`
- No `using Newtonsoft.Json`
- No `INotifyPropertyChanged` on domain types themselves (only on head-project ViewModels/models)
- Every domain class needs a **parameterless constructor** and a
  **`protected constructor(Object? object)` copy constructor**

**`Result<T>` / `Error`:** the standard return type for operations that can fail.
Prefer `Result<T>` at Application boundaries instead of throwing exceptions.

---

## Application Layer (`ALPAMobile.Application`)

**What lives here:** interfaces that describe what the app needs, plus orchestration
services that implement use-cases using only those interfaces.

```text
ALPAMobile.Application/
├── Abstractions/          # Feature-scoped interfaces consumed by head / Presentation
│   ├── Authentication/    # IAuthService, ITokenProvider
│   ├── Persistence/       # IDocumentDatabase, IDocumentStore<T>
│   ├── Remote/            # IRestService, IRestEndpointProvider
│   ├── FlightTimeDutyTime/# IFTDTStore, IFTDTCalculationEngine, IFTDTSyncService
│   └── …                  # one folder per feature domain
├── Services/              # Orchestration — depends only on Abstractions + Domain
│   └── FlightTimeDutyTime/
│       ├── CAN/           # §§700.40–700.43 rule classes
│       ├── USA/           # §117.25 rule classes
│       ├── FTDTCalculator_CAN.cs
│       ├── FTDTCalculator_USA.cs
│       └── RestRulesCalculationEngine.cs
├── ApplicationServiceCollectionExtensions.cs  # AddApplication() DI entry point
└── AssemblyMarker.cs      # Used by NetArchTest to load the assembly
```

**Rule:** everything in `Application` depends only on `Domain` and the BCL.
If you need a database or HTTP call, define an interface in `Abstractions/`
and let `Infrastructure` implement it.

**Adding a new feature:**

1. Define the interface in `Application/Abstractions/<Feature>/IMyService.cs`
2. Implement orchestration logic in `Application/Services/<Feature>/MyService.cs`
3. Add the concrete implementation in `Infrastructure/`
4. Register both in the appropriate `*ServiceCollectionExtensions.cs`

---

## Infrastructure Layer (`ALPAMobile.Infrastructure`)

**What lives here:** everything that touches the outside world.

```text
ALPAMobile.Infrastructure/
├── Persistence/     # LiteDB: LiteDbDocumentDatabase, CacheDatabase
├── Remote/          # HTTP: RestService, API client wrappers
├── Caching/         # In-memory and disk caches
├── Security/        # MSAL token management
├── Diagnostics/     # Sentry integration
└── InfrastructureServiceCollectionExtensions.cs
```

Infrastructure classes implement Application interfaces — they **never** define
their own contracts. If `Application` needs a store, it defines `IDocumentStore<T>`
and Infrastructure provides `LiteDbDocumentStore<T>`.

---

## Head Project / Composition Root (`ALPAMobile`)

**What lives here (for now):** everything not yet migrated — pages, ViewModels,
the legacy `DataManager`, and the DI wiring in `MauiProgram.cs`.
Also the only project allowed to reference MAUI-platform types.

```text
MauiProgram.cs
├── .AddApplication()     # registers Application-layer services
├── .AddInfrastructure()  # registers Infrastructure implementations
└── …                     # registers MAUI-bound platform adapters, ViewModels, pages
```

**`DataManager.cs`** is the legacy monolith (~2800 lines) that owns most persistence,
network, and business logic today. It is being broken up incrementally by extracting
feature-scoped interfaces into Application and moving implementations to Infrastructure.

**Do not add new logic to `DataManager.cs`.** Define a new Application interface and wire it instead.

---

## Feature Flag Gate (`GatedFTDTCalculationEngine`)

New engines that are not ready for production ship behind a feature gate:

```csharp
// Debug builds  → shadow mode (both engines run; new output logged, not displayed)
// Release builds → legacy engine only (safe default)
// Backend kill-switch → key "FTDTRestRulesEngine" in AppPropertiesFeatureFlags JSON
```

The gate is registered in `MauiProgram.cs` and resolved as `IFTDTCalculationEngine`.
UI and tests bind to the interface and are unaware of which engine is active.

---

## Raw-to-Domain Mapping

Never instantiate domain objects directly from raw DTOs or JSON strings in service logic.
Use a dedicated factory:

```csharp
var domainObj = _factory.CreateFromRaw(rawPayload);
```

The FTDT sync path uses `FTDTDutyPeriodMapper` (hand-rolled, not AutoMapper) to convert
between `DutyPeriod` domain subclasses and `DutyPeriodExpanded` sync DTOs.

## Content-to-Component Mapping (WI-2262)

The mirror-image direction — Domain content → component-library ViewModel — goes through
dedicated adapter factories, never inline field-by-field assignment in a page or ViewModel:

| Factory | Maps | Consumers |
| --- | --- | --- |
| `ItemComponentFactory` | dynamic-feed `ItemDto` → `ComponentViewModel` (dispatched on `ItemType`) | `Home.razor`, `HomePreviewPage.razor` |
| `MenuItemCardFactory` | `MenuItem` → `CardSmallViewModel` (web-servable image check + icon-mask fallback) | `FavoritesViewModel` |
| `DocumentHeroFactory` | `DocumentItem` → `CardHeroViewModel`; also owns the `/document-open` route policy (`OpenRoute`) | `FavoritesViewModel`, `DocumentsListPage.razor` |
| `CategoryCardFactory` | GetDocuments scope+category → `CardSmallViewModel`; owns the `{scope}\|{categoryKey}` raw-id and `/documents` deep-link policies (D62, Task AB#2271) | `FavoritesViewModel`, `MockHomePreviewFeedService` |
| `ContainerFactory` | dynamic-feed `ContainerDto` → `ContainerSurfaceViewModel` subclasses (dispatched on `ContainerType`, §4/D28); delegates every item to `ItemComponentFactory`; unknown types skip (`TryCreate` → null, forward compat) | `PageFactory` |
| `PageFactory` | dynamic-feed `PageDto` → `PageViewModel` (the component-tree root; unknown containers filtered) | `HomePreviewPage.razor` (via `HomeFeedQueriesRouter`, WI-2270/D63) |

All six live in `ALPAMobile.Presentation/Components/` (the first three migrated with the
renderers 2026-07-15, WI-2254/D61; `CategoryCardFactory` added 2026-07-16, D62;
`ContainerFactory`/`PageFactory` added 2026-07-17, WI-2270/D63). The dynamic-feed wire
contract — `ItemDto`, and since WI-2270 the three-level `PageDto`/`ContainerDto` — lives in
`ALPAMobile.Application/ApiModels/` beside the query ports (namespace `ALPADocs.ApiModels`).
Same purity rule as raw-to-Domain factories: pure mappers,
no `IServiceProvider`, no async — the data lookup stays with the caller.

Favorite identity travels WITH the ViewModel: each factory stamps
`ComponentViewModel.FavoriteItemTypeId` for the content it maps, and the renderer
(`ComponentView`) reads it with per-component defaults — a visual component type never
implies a content type. The wire `Favorite.ItemId` is the composite
`{typePrefix}-{rawId}` convention owned by `FavoriteKey` (Domain), composed at the
favorites boundary; adapters and ViewModels carry raw ids (D62).

All mappings are contract-tested: `UnitTest/ItemComponentFactoryTests.cs` pins every
`ItemDto.ItemType` wire discriminator to its ViewModel and field mapping (plus a realistic
full-feed `CreateAll` end-to-end case), `UnitTest/ContainerFactoryTests.cs` pins the four
`ContainerType` discriminators plus the unknown-container skip and a contract-§6-shaped
camelCase end-to-end via `AlpaWireJson`, and `UnitTest/ContentComponentFactoryTests.cs` covers
the MenuItem/DocumentItem factories. A wire-discriminator test breaking means the API contract
moved — that's a backend-coordination event, not a refactor.

Architecture diagrams (big picture, rendering pipeline, adapter map):
[dynamic-ui-architecture.html](dynamic-ui-architecture.html) §1.1 / §3 / §3.1.

---

## Architecture Tests

Tests live in `UnitTest/Architecture/LayerDependencyRulesTests.cs` using **NetArchTest.Rules**.

| Test | What it enforces |
| --- | --- |
| `Domain_Should_Not_Reference_Maui` | No MAUI types in Domain |
| `Domain_Should_Not_Reference_Infrastructure_Concerns` | No LiteDB/HTTP/MSAL/Sentry/Newtonsoft in Domain |
| `Application_Should_Not_Reference_Infrastructure` | Application never imports Infrastructure |
| `Application_Should_Not_Reference_Concrete_Infrastructure_Concerns` | No LiteDB/HTTP/MSAL/Newtonsoft in Application |
| `Application_Should_Not_Reference_Maui` | Application targets `net10.0` — no MAUI |
| `Presentation_Should_Not_Reference_Infrastructure` | Presentation binds Application/Domain abstractions, never concrete Infrastructure |
| `Presentation_Should_Not_Reference_Maui` | Presentation stays MAUI-free — neutral DTOs, head converters adapt |
| `PresentationRazorFiles_ShouldNotUseUnresolvableComponentTags` | Every `<PascalCase>` tag in a Presentation `.razor` resolves to a real component |

The last one lives in `UnitTest/Architecture/RazorComponentResolutionTests.cs` and scans source
rather than assemblies, because by compile time the mistake it catches has already been erased
into markup.

Running them:

```bash
dotnet test UnitTest/UnitTest.csproj --filter "LayerDependency"
```

If a test fails it prints every offending type name. Fix the dependency — do not disable the test.

---

## DI Composition Flow

```text
MauiProgram.cs
  ├── services.AddDomain()
  ├── services.AddApplication()       → ApplicationServiceCollectionExtensions
  ├── services.AddInfrastructure()    → InfrastructureServiceCollectionExtensions
  ├── services.AddHeadServices()      → HeadServiceCollectionExtensions
  │                                     platform adapters, *QueriesRouter services,
  │                                     mock API services, and the Blazor navigation
  │                                     policies (DeepLinkResolver, NotificationContentRouter)
  └── services.AddPresentation()      → PresentationServiceCollectionExtensions
                                        XAML-bound ViewModels
```

When adding a new service:

- **Application service** → register in `AddApplication()`
- **Infrastructure implementation** → register in `AddInfrastructure()`
- **MAUI platform adapter** (SecureStorage, Connectivity, …) → `AddHeadServices()`
- **Blazor navigation policy or other Presentation-layer helper** → `AddHeadServices()`

Note the split that trips people up: a type can *live* in `ALPAMobile.Presentation` and still be
*registered* from the head. The head is the composition root — it is the only project that sees
every layer — so registration happening there says nothing about where the type belongs.
`NotificationContentRouter` is the worked example.

---

## Migration State

Phase titles below are the **work items' own titles**. An earlier version of this table
paraphrased Phases 3 and 4 as "service-locator removal" and "DataManager breakup" — neither was
ever in their scope, which made two delivered Features read as permanently in progress and left
the work that *is* outstanding looking like it had an owner when it does not. See
*Not owned by any phase* below.

| Phase | Work Item | State | Scope |
| --- | --- | --- | --- |
| 0 | WI-2082 | ✅ Resolved | NetArchTest guardrails + CI gate |
| 1 | WI-2083 | ✅ Resolved | Layer projects scaffolded |
| 2 | WI-2084 | ✅ Resolved | FTDT engine extracted to Application layer |
| 3 | WI-2085 | ✅ Resolved | Application Layer & Interface Extraction — `INavigationService`, `ISettingsService`, 14 feature query ports under `Application/Abstractions/` |
| 4 | WI-2086 | ✅ Resolved | Infrastructure Layer — LiteDB stores, HTTP/REST, MSAL, Sentry behind Application interfaces |
| 5 | WI-2087 | ✅ Resolved | Presentation layer extracted (2026-07-01); component library + ViewModels moved into the `ALPAMobile.Presentation` RCL 2026-07-15 (WI-2254/D61); Blazor pages followed |

Epic **WI-2081** stays Active because of the items below, not because a phase is unfinished.

### Not owned by any phase

Both are real, both are measured, and neither is covered by a phase Feature:

- **Service-locator cleanup — 98 `GetService<T>()` call sites** across the head and Presentation
  (worst: `MauiProgram.cs` 11, `ViewModelServices.cs` 11, `AppShellViewModel.cs` 9). Tracked as
  **WI-2105** (Active), *not* Phase 3. Note the ViewModels' lazy accessors are deliberate and do
  **not** indicate a DI cycle — `ValidateOnBuild` proved that; the fix is `ActivatorUtilities`
  factories replacing manual `new VM(runtimeData)`.
- **`DataManager.cs` (2,299 lines) and `RestService.cs` (2,064 lines)** still in the head. The
  14 query ports exist and new code binds them, but the god-classes behind them were never
  decomposed. No work item.

**Where the code actually sits** (measured 2026-08-03 — re-measure before trusting, and count
**recursively**: the first pass at these numbers used a non-recursive glob and missed
`Components/Pages/FTDT/`, undercounting both columns):

| | Head (`ALPAMobile`) | `ALPAMobile.Presentation` |
| --- | --- | --- |
| Blazor pages | 3 | 54 |
| XAML pages | 55 | 0 |
| ViewModels | 47 | 59 |

**Still in the head project (known debt):**

- `DataManager.cs` / `RestService.cs` — see *Not owned by any phase* above.
- **XAML pages and their ViewModels** — these are the MAUI-bound UI and stay until the screen
  itself is replaced by a Blazor page. This is not drift.
- **3 Blazor pages, each with a stated reason** (AB#2087 moved the other six out):
  `UiTestReturnNativePage` drives `Shell.Current` to hand control back to native navigation;
  `CommsReadStateScenariosPage` binds `MockNotificationApiService` and `FTDTTestScenariosPage`
  binds `FTDTTestDataService`, both head services that seed test data. Putting a seeding fixture
  behind an Application port is the wrong shape — the port would exist only to let a dev surface
  write synthetic records. Each says so at the top of the file. Do not add to this list without a
  reason that survives the questions in the TLDR — "it was written there first" is not one.
- `BusinessLogic/FTDTCalculator_*.cs` — legacy calculators; do not modify;
  new engine is in `Application/Services/FlightTimeDutyTime/`.

---

## Common Pitfalls

**Adding `using Microsoft.Maui` to an Application or Domain file**
→ CI fails immediately. Application targets `net10.0`. Move MAUI-dependent code to the
head project or an Infrastructure adapter.

**Calling `GetService<T>()` inside a constructor or method body**
→ Service-locator anti-pattern (WI-2105). Inject via constructor instead.
`GatedFTDTCalculationEngine` demonstrates the correct pattern.

**Putting INPC or `ObservableCollection` in Domain or Application**
→ Architecture tests catch it. INPC belongs only in head-project ViewModels and Models.

**Moving a Blazor component between projects and leaving a consumer's tag behind**
→ **Razor does not error on a component tag it cannot resolve.** It emits
`<ZoomableRegion cssclass="…">` as an unknown HTML element, so the build stays green while the
markup silently does nothing. This shipped: `AlpaScreen` moved to Presentation, `ZoomableRegion`
stayed in the head, and for weeks the page body had no `alpa-screen-content` class — the tab bar
floated ~245px above the bottom of the screen, pinch-zoom did nothing, and scroll restoration
had no element to target. `RazorComponentResolutionTests` now catches it. When you move a
component, move or check every tag that uses it.

Note the asymmetry, because it decides how much the compiler will help you: a **C# type**
crossing the boundary is a build error (this is how the same mistake with `DeepLinkResolver` was
caught in minutes); a **Razor tag** is not.

**Putting dispatch or routing policy in a page code-behind**
→ It can then only be exercised on a device. Prefer a small injectable resolver in
`ALPAMobile.Presentation/Components/Navigation/` that *returns a decision* while the page *acts*
on it — see `DeepLinkResolver` and `NotificationContentRouter`, both unit-tested, against the
same logic that was untestable while it lived in `NotificationsPage`.

**Adding logic to `DataManager.cs`**
→ Don't. Define an Application interface for the feature and wire it via DI.

**Extending a NuGet-generated `partial class` from source**
→ The compiler creates a duplicate type, not a merged one. Use extension methods or a
wrapper instead (see `FTDTDutyPeriodMapper` for the pattern used with `DutyPeriodExpanded`).

**Modifying legacy FTDT calculators** (`ALPAMobile/BusinessLogic/FTDTCalculator_*.cs`)
→ These are frozen. All new FTDT logic goes in `ALPAMobile.Application/Services/FlightTimeDutyTime/`.

---

## Further Reading

| Doc | Path |
| --- | --- |
| N-Tier planning handoff (phases + gap tasks) | `docs/n-tier-orchestration-handoff.html` |
| FTDT engine spec | `docs/FTDT/` |
| Backend sync handoff (WI-1908) | `docs/detail/BACKEND-HANDOFF-1908-CAN-REST.md` |
| Features & roadmap | `docs/detail/features.html`, `docs/detail/roadmap.html` |
| Testing guide | `docs/detail/testing.html` |
| Development setup | `docs/detail/development.html` |
