Service layer decoupling — guide

Updated: 2026-09-23

TLDR

Read top to bottom and stop when you have enough. Each level adds detail; none repeats the level above.

Level You get Read if you…
1 The 2-minute picture are reviewing PR 2543 or onboarding
2 How a request flows are changing or adding a service
3 The building blocks need the exact type and its rule
4 Phase by phase want to know what landed where, and why
5 Recipes and guardrails are about to write code in this area
6 Status and reference docs need evidence, ledgers, or open gates

Level 1 — the 2-minute picture

The problem

Every domain (MEC, documents, notifications, …) re-implemented the same block by hand inside DataManager: check connectivity → check auth → call RestService → save to cache → on failure read stale cache. HTTP, retry, bearer-token handling and even UI snackbars lived in RestService. Login, logout and token refresh lived in Authentication, which also reached into the cache and theme/feed on logout.

Consequences: no unit could be tested alone, the head (MAUI app) project owned logic that has no MAUI dependency, and a slow response arriving after logout could repopulate the cache with the previous member's data.

The shape now

Consumers (ViewModels, Razor pages)
   │  inject narrow ports — unchanged names, e.g. IMECQueries, IDocumentsQueries
   ▼
ALPAMobile.Application        ports + orchestration (CacheContentService, prefetch)
   │
   ▼
ALPAMobile.Infrastructure     one focused service per domain (MecService, DocumentQueries, …)
   │                          per-domain API client → one shared transport → CachedRemoteQuery → ICacheStore
   ▼
Head (ALPAMobile)             only what needs MAUI: navigation, native file/viewer, push, connectivity

The five ideas to hold onto

# Idea One-line meaning
1 One home per concern A domain service in Infrastructure behind the port it already exposed.
2 One transport Every JSON call goes through IRequestProvider; one place owns retry, timeout, 401 refresh.
3 One cache pattern CachedRemoteQuery replaces the copy-pasted fetch/cache/fallback blocks.
4 Generation guard Work started under an old session cannot write memory, disk, or Search rows.
5 Behavior preserved on purpose Keys, JSON shapes, routes and per-domain fallback rules are pinned by tests.

Level 2 — how a request flows

Example: a signed-in member opens the MEC committees list.

  1. The ViewModel calls IMECQueries (an Application port). It has never known who implements it.
  2. DI resolves it to the Infrastructure MecService singleton (formerly forwarding code in DataManager).
  3. MecService asks CachedRemoteQuery for the data, naming the cache key (GetMECCommitteesAsync, unchanged), a codec (legacy Newtonsoft shape) and a policy.
  4. CachedRemoteQuery captures the current cache generation, then calls the MEC API client.
  5. The API client sends through the authenticated request provider, which adds the bearer token. Its handling of failures:
  6. On success, the result is written to the cache only if the generation is still current. The same write updates the Search index, atomically.
  7. On a typed transient failure, the persisted copy is returned. Cancellation and session change are never turned into stale data.

Logout, the case this design exists for

  1. Logout revokes the session epoch and cache generation synchronously.
  2. Any in-flight request from step 4 above finishes later, sees a stale generation, and its result is discarded (a context-invalidated outcome, not a stale fallback).
  3. Ordered logout participants then clear token, credentials and caches. A failure in one participant does not stop the rest.
  4. The next member's sign-in waits for cleanup, so it starts from an empty, unpolluted context.

Level 3 — the building blocks

Paths are relative to the repository root.

Transport and endpoints

Type Location Rule
IRequestProvider / RequestProvider ALPAMobile.Infrastructure/Remote/RequestProvider.cs Raw JSON transport returning Result<T>. Used directly for token exchange, which must not be decorated.
AuthenticatedRequestProvider ALPAMobile.Infrastructure/Remote/AuthenticatedRequestProvider.cs Decorator that adds bearer tokens, retries, and one 401 replay.
ApiEndpointOptions ALPAMobile.Application/Abstractions/Remote/ApiEndpointOptions.cs Single source of truth for hosts, validated on start. Replaces RestService.SetEndpoints().
IApiEndpointCoordinator ALPAMobile.Application/Abstractions/Remote/ Settings surfaces publish environment changes through it; clients read the current snapshot per request.

Transport defaults (from the implementation evidence):

Setting Value
Sends per GET 3 (MaxRetries = 2)
Per-attempt timeout 15 s (includes body read)
Backoff 2 s then 4 s, plus jitter
Total budget 60 s, covering backoff, token acquisition and the 401 replay
DocumentsApiClient 30 s per attempt, 90 s total (getdocuments can take 30 s)
JWT exchange 1 retry, transport failure only (a lost reply to a rejected password still counts toward lockout)
Ordinary writes sent once

Replay-safe exceptions that retry despite being non-GET: notification list reads, push-registration upserts, document download reads, flight-search read POSTs, favorite DELETEs.

Cache

Type Location Rule
ICacheStore ALPAMobile.Application/Abstractions/Caching/ICacheStore.cs Port over the LiteDB cache (CacheDatabase implements it). Keys and JSON format unchanged.
CacheContextCoordinator ALPAMobile.Infrastructure/Caching/CacheContextCoordinator.cs Owns the generation. Writes run inside TryExecute with the generation captured before the fetch.
CachedRemoteQuery ALPAMobile.Infrastructure/Caching/CachedRemoteQuery.cs Takes an explicit key, codec and policy. Reports live / memory / persisted / missing. Shares in-flight work between callers.
*CacheCodecs ALPAMobile.Infrastructure/Caching/ Read and write the legacy persisted shape. Domain shape is not the wire shape.

Two named policies replace the old copy-pasted shapes: fallback on transport failure and stale-while-revalidate (documents).

Two hard constraints, both pinned by tests:

Domain services

One focused service per domain, all in ALPAMobile.Infrastructure, resolved through the existing ports:

Domain Implementation
MEC / LEC Services/Mec/MecService
Content, Member, Jumpseat/KCM, Notifications, Subscriptions, FlightSearch Services/*
Documents Services/Documents/DocumentQueries (facade over catalogue and local store)
Menu, Favorites Services/MenuService, Services/FavoritesService
FTDT reads Services/FlightTimeDutyTime/FtdtQueries, over IFTDTDutyPeriodRepository

Prefetch orchestration (CacheContentService) lives in Application (Services/Caching/). It talks to platform concerns only through ports.

Authentication

IAuthentication (24 members) is split into four ports, all aliased to the one Authentication singleton:

Port Responsibility
ISessionState Read-only session state and change events
IAuthTokenProvider Bearer acquisition
ICredentialStore Secure credential storage
IAuthSession Sign-in and sign-out actions

IAuthentication survives only as an [Obsolete] composite for legacy fixtures; an architecture guard forbids new production consumers. Authentication no longer depends on a REST aggregate, the cache database, or a service provider. Sign-in captures an epoch; a superseded attempt cannot publish anything.

Guards (tests that keep this from regressing)

Test Location (UnitTest/Architecture/) Enforces
HeadConsumerRatchetTests + head-consumers.json same No new head-project dependency on migrated services; scans IL and source, including service-locator calls.
LayerDependencyRulesTests same Infrastructure stays MAUI-free.
CacheWriteBoundaryTests same No raw cache write outside a generation-guarded owner.
RemoteRawBoundaryTests same Wire DTOs are mapped by raw-to-domain factories, not deserialized into domain objects.

Level 4 — phase by phase

Each phase was its own PR into imp/blazor-hybrid-service-layer-decoupling. Detailed criteria and evidence are in SERVICE-LAYER-IMPLEMENTATION.html.

Phase PR What landed Demonstrable outcome
0 — Safety baseline 2505 Cache generations, legacy-behavior fixtures, executable dependency inventory A response released after logout cannot restore cache or Search rows
1 — Transport 2509 Endpoint options, raw and authenticated transport, CachedRemoteQuery A GET recovers once from 401; a write is not replayed
2 — MEC pilot 2510 Six MEC/LEC partitions behind IMECQueries Committees load live, then offline, under the same search key
3 — Remaining domains 2511 Content, Member, Jumpseat/KCM, Notifications, Subscriptions, FlightSearch, prefetch Per-domain fallback rules preserved
4 — Documents 2512 Shared catalogue producer, hydration, file cache Two callers join one fetch; cancelling one leaves the other working
5 — Authentication 2513 Four auth ports, ordered logout participants Logout revokes the old context before handlers can start new work
6 — Transport retirement 2514 Menu, Favorites, FTDT, logging moved; RestService deleted Logging sends once; the queue owns retry
7 — Facade retirement 2515 DataManager deleted; sign-in context reset Every query port resolves without constructing DataManager
Review remediation 2527 (incl. 2534) 118 review findings dispositioned; R01–R11 second pass See resolution ledger

Phase PR numbers come from the implementation evidence and the PR 2527 integration plan; check Azure DevOps for current merge state.

Deliberate refinements (things that are not pure moves)


Level 5 — recipes and guardrails

Add a query to an existing domain

  1. Add the method to the domain's existing Application port only if the port genuinely needs it.
  2. Add the API client call in the domain's Infrastructure client, sending through the shared provider. Do not create an HttpClient.
  3. In the domain service, call CachedRemoteQuery with an existing or newly registered key, a codec, and the policy that matches the legacy behavior for that domain.
  4. Map wire data with a RawRepresentationFactory<T>; the factory stays a pure mapper.
  5. Add tests: route and payload, key and JSON shape, offline fallback, auth-required behavior, late result after generation change.

Add a new domain

Follow MEC (MecService), the pilot: port in Application, service and API client in Infrastructure, codec beside the other codecs, DI in InfrastructureServiceCollectionExtensions. If the key should be searchable, onboard it per SEARCH-CONTENT-ONBOARDING.md.

Rules that are easy to break

Rule Why
Never write the raw API cache outside a TryExecute with a pre-fetch generation Reopens the post-logout repopulation race; CacheWriteBoundaryTests will fail
Never rename an existing cache key Cold cache and blank Search after upgrade
Never change a persisted JSON shape as a side effect No cache purge is part of this work; old installs must still read their data
Never add automatic retry to a non-idempotent write A lost reply can double-apply, and for JWT exchange it counts toward lockout
Never inject IAuthentication in new code Use the narrow port you need
Never put a MAUI type in Infrastructure LayerDependencyRulesTests; use a head adapter behind a port
Configuration goes through the Options pattern, not the factory Keeps factories pure; see the project rule on RawRepresentationFactory

Legacy code you may still meet


Status and open gates

PR 2543 carries the whole refactor into imp/blazor-hybrid. Its own description is the source of truth for the current state; the table below reflects the validation record at code revision 342d355e:

Item State
Automated validation 2,985 Debug / 2,969 Release / 4 Docs passing; iOS and Android Debug/Release builds with 0 errors
Device walkthroughs iOS and Android functional and live walkthroughs completed per the PR description
Logged-out Home paint (was the hold) Corrected in d30a8d90 and 342d355e: logged-out Home renders from cached or embedded styling through the existing orchestrator, then refreshes live theming. On an iPhone 17 Pro Max simulator (iOS 26.5), Home paint with a 3-second delayed site response went from 3,057 ms to 61 ms. Details and the full matrix are in the validation record.
Merge policy Default merge only (no squash or rebase); one human approver; auto-complete off
Not part of this work AB#2857 push tags, AB#2858 hub tags, AB#2859 theme overwrite, AB#2860 secret scan

Reference docs

Doc Use it for
Decoupling plan Design gates, phase acceptance criteria, risks, open questions
Implementation evidence Per-phase behavior notes and criterion dispositions
Phase 0 inventory Dependency ratchet, route/serializer dispositions, persisted-JSON decisions
Review 2026-09-21 The 118 original findings
Review resolution Disposition of each finding
Review validation Exact SHAs, suite counts, native builds
Test migration map Where each removed legacy test now lives
N-Tier architecture Layer rules and project structure