
Domain Architect
- 181 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
domain-architect: A skill for development. This provides functionality for development workflows.
Key points
- domain-architect
Domain Architect by the numbers
- 181 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #2,201 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill domain-architectAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 181 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use domain-architect for development tasks?
Use domain-architect for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with domain-architect.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use domain-architect for development tasks, or when domain-architect: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to domain-architect: domain-architect.
Files
Domain Architect
You discover business domains by tracing what users can DO — the product's capabilities — and mapping each capability to a vertical slice through the architecture.
You do NOT start from folder names, architecture docs, or file counts. You start from the product.
What You Produce
A domain map — the single artifact that drives everything else:
Domain Map
├── Business Domains (vertical slices the user would recognize)
│ └── Per domain: Types, Config clients, Service reducers, UI views
├── Providers (external SDK bridges)
├── Cross-Cutting Concerns (Infra, Utils)
└── Questions (ambiguous boundaries to discuss with the team)Once the domain map is right, folder structure, SPM targets, enforcement specs, and migration plans all follow mechanically. Get the domains wrong and everything downstream is wrong.
How Domains Work
Read references/architecture.md for the full layer spec. Read references/architecture.als for the formally verifiable model.
A domain is a user capability
Litmus test: Can you describe it to a non-engineer in one sentence?
- "Scheduling appointments" — domain (Calendar)
- "Collecting payments" — domain (Payments)
- "Browsing available treatments" — domain (Treatments)
- "Handling HTTP requests" — NOT a domain (infrastructure)
- "Formatting dates" — NOT a domain (utils)
Each domain owns a vertical slice
Types → pure data definitions for this domain's nouns
Config → @DependencyClient interfaces (what you can ask for)
Repo → implementations (how it's done — API, persistence, sync)
Service → @Reducer state machines (business decisions)
Runtime → dependency wiring (Config interfaces → Repo implementations)
UI → SwiftUI views (pixels)Not every domain needs every layer. A thin domain might only have Config + Service + UI. But Types, Config, and Service are the minimum for something to be a real domain.
Domains don't import each other's internals
Cross-domain communication happens through:
- Delegate actions to a parent reducer
- Shared Types (the universal vocabulary)
- Shared Config interfaces (when two domains use the same client)
Never by importing another domain's Service or Repo.
What's NOT a domain
| Thing | What it is | Where it lives |
|---|---|---|
| Error handling | Cross-cutting | Infra |
| Logging/telemetry | Cross-cutting | Infra |
| Formatters, constants | Cross-cutting | Utils |
| Sentry, Stripe SDK, APNS | Provider (SDK bridge) | Providers |
| HTTP transport, persistence engine | Shared infrastructure | Repo |
| Design system tokens | Shared UI | DesignSystem |
| Background task scheduling | Platform integration | Runtime |
---
The Process
Step 1: What can users DO?
Start from the entry point. Read @main, the root reducer, the tab structure. Every child scope or tab is a candidate domain.
grep -r "@main" <project-root> --include="*.swift" -lRead the root reducer. Trace its Scope and CombineReducers to find every child feature. Trace the tab enum to find every top-level capability.
For multi-app products (e.g., patient app + clinic app), do this for EACH app. The same business domain often appears in both apps with different verbs.
Step 2: What verbs exist?
Every @DependencyClient is a verb — a capability the system can perform.
grep -r "@DependencyClient" <project-root> --include="*.swift" -lRead each client file. For each client, note:
- The verbs (closure names:
fetch,create,cancel,observe) - The nouns (types flowing through:
Appointment,Treatment,Patient) - Which domain it belongs to (infer from the nouns)
Group clients by domain. This gives you the Config layer map.
Step 3: What nouns exist?
The nouns are in the Types layer — the universal vocabulary.
find <project-root> -name "Package.swift" -not -path "*/.build/*"Read Package.swift files to find the Types target. Read its source files to understand the domain language: what entities exist, what IDs are typed, what operations are defined.
Step 4: Map each domain's vertical slice
For each domain discovered in Steps 1-2, trace its full vertical:
| Layer | Question | How to find it |
|---|---|---|
| Types | What nouns does this domain speak? | Grep for domain nouns in Types package |
| Config | What can you ask for? | The @DependencyClient files from Step 2 |
| Repo | How is data fetched/stored? | Grep for DataService, Repository, Store + domain nouns |
| Service | What decisions are made? | Grep for @Reducer + domain name |
| Runtime | Where is it wired? | Grep for Registration + domain name |
| UI | What does the user see? | Grep for View + domain name |
Read at least one file per layer per domain. Don't guess from names.
Step 5: Identify providers
Providers wrap external SDKs. They're NOT domains — they're bridges.
Look for:
- Third-party framework imports (Stripe, Firebase, Sentry, Amplitude)
- SDK initialization code
- Protocol conformances that bridge external types to domain types
grep -r "import Stripe\|import Firebase\|import Sentry\|import Amplitude" <project-root> --include="*.swift" -lStep 6: Identify cross-cutting concerns
What's left after domains and providers? Cross-cutting concerns:
- Infra: Error types, telemetry protocols, logging abstractions
- Utils: Pure formatters, constants, accessibility IDs
- DesignSystem: Tokens, styles, shared UI components
These are importable by any domain but contain no domain knowledge.
Step 7: Flag ambiguities
Some things are genuinely ambiguous. Flag them as questions:
- "Is Profile a domain or part of Auth?" — Profile has its own client
and UI, but avatar upload goes through Auth. Discuss with the team.
- "Is Booking part of Calendar or its own domain?" — It has distinct
clients but lives inside the Calendar tab. Depends on complexity.
- "Are Notifications a domain or cross-cutting?" — It has its own UI
and client, but very thin Types. Borderline.
Present these as questions, not decisions. The team has context you don't.
---
Output Format
Domain Map
For each domain:
### [Domain Name] — "[one-sentence description]"
**Nouns**: [Types this domain speaks — Appointment, Treatment, etc.]
**Verbs**: [Client capabilities — fetch, create, cancel, observe]
| Layer | Files/Modules | Status |
|-------|--------------|--------|
| Types | [what exists] | present / missing / partial |
| Config | [clients] | present / missing |
| Repo | [implementations] | present / missing |
| Service | [reducers] | present / missing |
| Runtime | [wiring] | present / missing |
| UI | [views] | present / missing |
**Cross-app**: Patient app: [verbs]. Clinic app: [verbs].Providers
| Provider | SDK | Used by domains |
|----------|-----|----------------|
| Sentry | Error monitoring | All (Infra) |
| Stripe Terminal | In-person payments | Payments |Cross-Cutting
| Concern | Layer | Purpose |
|---------|-------|---------|
| AppDomainError | Infra | Error vocabulary |
| Telemetry | Infra | Monitoring |
| AccessibilityId | Utils | UI testing |Questions
1. Is Profile a domain or part of Auth? [evidence for each]
2. Should Booking be extracted from Calendar? [evidence]---
Anti-Shortcut Rules
1. Start from the product, not the files. "What can users do?" comes before "what files exist?"
2. Do not classify by folder path. A file in Services/ might be UI code. Read before classifying.
3. Do not read architecture docs before forming your own opinion. If harness-spec.yml or ARCHITECTURE.md exists, read it AFTER you've mapped the domains. Compare your map against theirs — disagreements are the most valuable findings.
4. Do not skip domains because they look similar. Each domain gets its own vertical trace. Auth is not Profile.
5. Use subagents for codebases with >200 files. One agent per domain, each traces the full vertical. This prevents shortcuts from context pressure.
6. Flag ambiguities instead of deciding. You lack the team's context. Present evidence for both sides. Let the team decide.
---
Depth Requirements
1. 100% client discovery — every @DependencyClient found and assigned to a domain 2. 100% reducer discovery — every @Reducer found and assigned to a domain 3. Vertical trace per domain — at least one file read per layer per domain 4. Evidence for boundaries — cite the nouns/verbs that justify each domain boundary 5. Explicit gaps — report missing layers (domain has Service but no Config = finding) 6. Questions over assumptions — when unsure, ask rather than guess
{
"version": "1.0.0",
"organization": "dot-skills",
"technology": "Swift Architecture",
"date": "May 2026",
"abstract": "Discovers business domains in Swift codebases by tracing user capabilities, mapping vertical slices, and producing domain maps for downstream architectural decisions."
}
// architecture.als — Formal model of the Harness layered domain architecture
// Verify with Alloy Analyzer 6: https://alloytools.org
//
// WHY A FORMAL MODEL?
//
// Build-time import scanning checks direct edges: "does file X import module Y?"
// A formal model checks REACHABILITY: "is there ANY path — direct or transitive —
// through which layer A can reach layer B?"
//
// This catches gaming: thin wrappers, re-exports through intermediate modules,
// type tunneling through generics. If a Config file re-exports Repo types,
// the import graph looks clean (Config → Repo is allowed). But the formal model
// detects that Service now TRANSITIVELY reaches Repo through Config — a violation
// of serviceRepoWall even though no individual import is forbidden.
//
// Two relations, not one:
// compileDependsOn — what can be `import`ed (compile-time, SPM / harness-spec)
// dataFlowsTo — what data reaches at runtime (through Config closures)
//
// The key insight: Repo data reaches Service at runtime through Config closures
// wired by Runtime. But Service cannot import Repo. Both are true simultaneously.
// ─── Layers ────────────────────────────────────────────────────────────────────
abstract sig Layer {}
// Domain layers — each domain has a vertical slice through some or all of these
one sig Types, Config, Repo, Service, Runtime, UI extends Layer {}
// Cross-cutting layers — outside the domain boundary
one sig Utils, Providers extends Layer {}
// ─── Files ─────────────────────────────────────────────────────────────────────
// Model at the FILE level, not just layer level. This is what catches gaming.
// Layer-level rules say "Service cannot import Repo." File-level modeling says
// "no file classified as Service can reach any file classified as Repo through
// any chain of imports."
sig File {
layer: one Layer,
domain: lone Domain, // lone = cross-cutting files have no domain
imports: set File // actual compile-time import edges (from codebase)
}
// ─── Domains ───────────────────────────────────────────────────────────────────
sig Domain {
files: set File
}
// Domain files are consistent with the domain relation
fact domainConsistency {
all d: Domain, f: d.files | f.domain = d
all f: File | some f.domain implies f in f.domain.files
}
// ─── Allowed compile dependencies (the RULES) ─────────────────────────────────
// These define what's PERMITTED. The `imports` relation on File captures what
// ACTUALLY EXISTS. Violations = imports that exist but aren't permitted.
fun allowedLayerDeps[from: Layer]: set Layer {
// Forward chain: Types → Config → Repo → Service → Runtime → UI
(from = Types) => none else
(from = Config) => Types else
(from = Repo) => Config + Types else
(from = Service) => Config + Types + Providers else
(from = Runtime) => Layer else
(from = UI) => Runtime + Service + Config + Types else
// Cross-cutting
(from = Providers) => Utils + Types else
(from = Utils) => none else
none
}
// THE WALL: Service can depend forward on Config and Types, but NOT on Repo.
// This is the defining constraint of the pattern. Service reaches Repo data
// at runtime through Config closures, never through compile-time imports.
// Note: `Service` is conspicuously absent from Repo's downstream — that's
// intentional and the entire reason Config exists as a separate layer.
// ─── Invariant 1: No illegal direct imports ────────────────────────────────────
// Every import edge must connect files whose layers are in the allowed set.
pred noIllegalDirectImports {
all f: File, dep: f.imports |
dep.layer in allowedLayerDeps[f.layer]
}
// ─── Invariant 2: No illegal TRANSITIVE reach (the anti-gaming invariant) ─────
// Even if every direct import is legal, a chain A → B → C might create a
// transitive path that violates the architecture. This is what catches:
// - Re-export wrappers: Config file imports Repo, re-exports types
// - Pass-through modules: Utils file wraps Repo, Service imports Utils
// - Type tunneling: generics parameterized with Repo types through Config
// Transitive closure of imports
fun reaches[f: File]: set File {
f.^imports // reflexive-transitive closure: all files reachable through import chains
}
pred serviceRepoWall {
// No Service file can reach ANY Repo file through any chain of imports
no f: File | f.layer = Service and some (reaches[f] & layer.Repo)
}
pred configCannotReachImplementations {
// No Config file can reach Service, Runtime, or UI files
no f: File | f.layer = Config and some (reaches[f] & layer.(Service + Runtime + UI))
}
pred typesReachesNothing {
// Types files have no imports at all
all f: File | f.layer = Types implies no f.imports
}
pred runtimeIsLeaf {
// No file outside Runtime imports a Runtime file
no f: File | f.layer != Runtime and some (f.imports & layer.Runtime)
}
pred uiCannotReachRepo {
// No UI file can reach any Repo file through any chain
no f: File | f.layer = UI and some (reaches[f] & layer.Repo)
}
// ─── Invariant 3: Acyclicity ───────────────────────────────────────────────────
// The import graph must be a DAG. No file can reach itself through any chain.
// This catches mutual dependencies that individual import checks miss.
pred noCycles {
no f: File | f in reaches[f]
}
// ─── Invariant 4: Domain completeness ──────────────────────────────────────────
// Every domain must own at least Config + Service + UI files.
// A domain without Config has no interface contract.
// A domain without Service has no business logic.
// A domain without UI has no user-facing capability.
pred domainsAreComplete {
all d: Domain |
some (d.files & layer.Config) and
some (d.files & layer.Service) and
some (d.files & layer.UI)
}
// ─── Invariant 5: Cross-domain isolation ───────────────────────────────────────
// Domains must not share Service or Repo files. Each domain's business logic
// and data access are private. Cross-domain communication goes through:
// - Delegate actions (Service → parent → other Service)
// - Shared Config interfaces (both domains depend on the same client)
// - Shared Types (value types are universal vocabulary)
pred crossDomainIsolation {
all disj d1, d2: Domain |
no ((d1.files & layer.(Service + Repo)) & (d2.files & layer.(Service + Repo)))
}
// Cross-domain imports must go through Config or Types, never Service-to-Service
pred crossDomainImportsAreClean {
all disj d1, d2: Domain |
all f1: d1.files & layer.Service |
all f2: f1.imports & d2.files |
f2.layer in Config + Types
// Service in domain A can import Config/Types from domain B, never Service/Repo
}
// ─── Invariant 6: Providers isolation ──────────────────────────────────────────
// Providers cannot access domain internals. They wrap external SDKs and expose
// typed interfaces. If a Provider imports Config or Service, it has domain
// knowledge — which means it's not cross-cutting, it's domain code misplaced.
pred providersCannotAccessDomains {
all f: File | f.layer = Providers implies
no (f.imports & layer.(Config + Service + Repo + Runtime + UI))
}
// ─── Data flow (runtime relation) ──────────────────────────────────────────────
// Distinct from compile dependencies. Data flows through Config closures
// that Runtime wires to Repo implementations.
pred dataFlows[from, to: Layer] {
from -> to in allowedLayerDeps[from] -> to // compile deps are a subset
or
(from = Repo and to = Service) // Repo → Service through Config closures
or
(from = Providers and to = Service) // Providers → Service through Config
}
// ─── Combined check ────────────────────────────────────────────────────────────
// All invariants must hold simultaneously
pred allInvariantsHold {
noIllegalDirectImports
serviceRepoWall
configCannotReachImplementations
typesReachesNothing
runtimeIsLeaf
uiCannotReachRepo
noCycles
domainsAreComplete
crossDomainIsolation
crossDomainImportsAreClean
providersCannotAccessDomains
}
// ─── Assertions (for Alloy Analyzer to verify) ────────────────────────────────
// "If every direct import is legal, does serviceRepoWall automatically hold?"
// Answer: NO — this is why transitive checking matters.
// A Config file that imports Repo (allowed) and is imported by Service (allowed)
// creates a transitive path Service → Config → Repo that violates the wall.
assert directImportsImplyWall {
noIllegalDirectImports implies serviceRepoWall
}
check directImportsImplyWall for 5 File, 2 Domain, 8 Layer
// Same for Config reaching implementations
assert directImportsImplyConfigIsolation {
noIllegalDirectImports implies configCannotReachImplementations
}
check directImportsImplyConfigIsolation for 5 File, 2 Domain, 8 Layer
// "If all invariants hold, is the import graph acyclic?"
assert invariantsImplyAcyclicity {
allInvariantsHold implies noCycles
}
check invariantsImplyAcyclicity for 5 File, 2 Domain, 8 Layer
// ─── Exploration commands ──────────────────────────────────────────────────────
// Find a valid 3-domain architecture
run validArchitecture {
allInvariantsHold
#Domain = 3
#File >= 9 // at least 3 files per domain (Config, Service, UI)
} for 12 File, 3 Domain, 8 Layer
// Find a COUNTEREXAMPLE: legal direct imports but serviceRepoWall violated
// This demonstrates WHY transitive checking is needed.
// Expected: Alloy finds an instance where Config re-exports Repo to Service.
run gamingExample {
noIllegalDirectImports
not serviceRepoWall
} for 6 File, 1 Domain, 8 Layer
// Find a valid data flow where Repo data reaches Service
run repoDataReachesService {
allInvariantsHold
dataFlows[Repo, Service]
} for 6 File, 1 Domain, 8 Layer
Layered Domain Architecture — Harness Pattern
What IS a Business Domain?
A business domain is a self-contained vertical slice of the product that a user or stakeholder would recognize as a distinct capability. It maps to a real-world workflow, not a technical concern.
Litmus test: Can you describe it to a non-engineer in one sentence?
- "Scheduling appointments" — Calendar domain
- "Managing patient records" — Patients domain
- "Collecting payments" — Payments domain
- "Finding and booking a sitter" — Booking domain
- "Sending messages between parties" — Messaging domain
Is a domain: Auth, Calendar, Patients, Forms, Treatments, Settings, Payments, Booking, Messaging, Notifications, Search
Is NOT a domain: Networking, error handling, logging, design tokens, formatters, persistence engines, animation utilities, accessibility helpers
A domain owns its full vertical: Config (interfaces) → Repo (data access) → Service (logic) → UI (views). Cross-domain communication happens only through delegate actions or explicit protocols — never by importing another domain's internals.
---
The Layer Chain
Within each business domain, code can only depend "forward" through a fixed set of layers. Cross-cutting concerns (auth, connectors, telemetry, feature flags) enter through a single explicit interface: Providers. Anything else is disallowed and enforced mechanically.
Types → Config → Repo → Service → Runtime → UIgraph TD
Utils
Utils --> Providers
subgraph BLD["Business logic domain"]
Providers --> AppWiring["App Wiring + UI"]
Providers --> Service
Service --> Runtime
Runtime --> UI
Runtime --> AppWiring
Types --> Config --> Repo
Repo --> Service
endDependency Direction
Arrows mean "feeds into" — if A --> B, then B depends on A. Reading the dependency direction for each layer:
Two relations, not one
The formal model (architecture.als) distinguishes compile-time dependencies from runtime data flow. This distinction is the key to understanding the architecture — and the key to catching violations that import scanning misses.
- compileDependsOn — what a layer can
import(enforced by SPM / spec) - dataFlowsTo — what data reaches at runtime (through Config closures)
| Statement | compileDependsOn | dataFlowsTo |
|---|---|---|
| Service uses Config interfaces | yes | yes |
| Service uses Repo implementations | no | yes (through Config closures) |
| UI accesses Repo directly | no | no |
| Runtime sees everything | yes | yes |
Service never import Repo. But Repo data reaches Service at runtime through Config closures wired by Runtime. Both are true simultaneously.
compileDependsOn (what you can import)
| Layer | compileDependsOn |
|---|---|
| Types | nothing |
| Config | Types |
| Repo | Config, Types |
| Service | Config, Types, Providers (NOT Repo — the wall) |
| Runtime | everything |
| UI | Runtime, Service, Config, Types |
| App Wiring | Runtime, Providers |
| Providers | Utils, Types |
| Utils | nothing |
dataFlowsTo (what data reaches at runtime)
All compileDependsOn edges, plus:
- Repo dataFlowsTo Service — through Config closures wired by Runtime
- Providers dataFlowsTo Service — through Config closures
The reverse is never allowed in either relation. These are enforced mechanically (SPM targets, harness-spec) and formally verified (architecture.als).
---
Layer Definitions
Types — The Universal Vocabulary
Pure data definitions that every layer can speak.
| Contains | Does NOT contain |
|---|---|
struct/enum value types (Appointment, Patient, BookingRequest) | Business logic or computed decisions |
Strongly-typed IDs (EntityID<Tag> → AppointmentID, ListingID) | Mutable state (var fields, @State, @Observable) |
| API operation specs (typed HTTP endpoint definitions) | Side effects, async/await, networking |
Codable/Equatable/Hashable/Sendable conformances | Any import beyond Foundation |
Typed error codes (ProblemCode, BookingErrorCode) | UI framework references (SwiftUI, UIKit) |
Immutable fields (let only) | Persistence (SwiftData, CoreData, GRDB) |
Guiding principle: If you deleted every other layer, Types would still compile with just import Foundation. It describes what data looks like, never what to do with it.
Parse, don't validate: Types should make illegal states unrepresentable.
// Bad — illegal states representable
struct Booking {
let status: String // could be anything
let sitterId: String // could be empty or malformed
let dates: [Date] // could be empty
}
// Good — illegal states unrepresentable
struct Booking {
let status: BookingStatus // enum: .pending, .confirmed, .cancelled
let sitterId: SitterID // newtype, validated at construction
let dates: DateInterval // always has start <= end
}---
Config — The Contract Layer
Declares what capabilities exist without knowing how they're implemented.
| Contains | Does NOT contain |
|---|---|
@DependencyClient struct definitions with closure signatures | Concrete implementations of those closures |
Value types that flow through clients (AppointmentDraft, BookingConfig) | @Reducer, business logic, state machines |
DependencyValues extensions registering clients | import Repo — Config cannot see implementations |
TestDependencyKey conformances for testability | import SwiftUI or import UIKit |
| Feature flags and environment-specific configuration | Networking, persistence, or SDK imports |
Guiding principle: Config is the seam between business logic and infrastructure. It declares what can be asked for without saying how.
Litmus test: If a type describes what you can ask for (fetch, create, delete) without saying how, it's Config. If it says how (URL, database query, retry logic), it's Repo.
// Config — declares the capability
@DependencyClient
struct BookingDataClient {
var fetch: @Sendable (BookingID) async throws -> Booking
var create: @Sendable (BookingDraft) async throws -> Booking
var cancel: @Sendable (BookingID) async throws -> Void
}---
Repo — The Infrastructure Bridge
Knows how to talk to the outside world — API, database, keychain, network — so that nothing else has to. This is the parsing boundary: raw external data enters here and well-typed domain objects exit.
| Contains | Does NOT contain |
|---|---|
APIClient, HTTP transport, request/response handling | @Reducer, state machines, TCA actions |
SwiftData @Model entities and local stores | SwiftUI views, UIKit, design tokens |
| Sync coordination (retry, queues, offline support) | Domain validation or business rules |
| Token refresh, keychain storage, app attestation | Knowledge of which feature uses the data |
| DTO-to-domain-type mapping at boundaries | Direct dependency on Service or Runtime |
Guiding principle: Repo answers "how do I get/store this data?" — never "what should I do with it?" At boundaries, use reportIssue for optional defaults, throw for missing required fields. Never silently swallow data.
// Repo — the parsing boundary
actor BookingRepository {
func fetchBooking(id: BookingID) async throws -> Booking {
let dto = try await apiClient.execute(FetchBooking(id: id))
// Parse at boundary — everything downstream gets a typed Booking
return try Booking(from: dto)
}
}---
Service — The Brain
Pure business logic. No pixels, no network calls — just decisions.
In TCA codebases, Service is @Reducer state machines. In non-TCA codebases, Service is pure functions and @Observable classes containing business rules.
Service depends on Config (interfaces) and Providers (cross-cutting), but never on Repo (the serviceRepoWall invariant). Repo data reaches Service at runtime through Config closures wired by Runtime — a compile-time wall with a runtime bridge. Service does NOT depend on Runtime or UI.
| Contains | Does NOT contain |
|---|---|
@Reducer struct with State, Action, body (TCA) | SwiftUI View, ViewModifier, @State, @Binding |
@ObservableState struct State: Equatable | import UIKit, import SwiftUI |
@Dependency(ClientType.self) injected interfaces | Design tokens, colors, fonts, spacing |
.run effects calling client closures | @Observable, @Published, @StateObject |
Parent-child composition (Scope, CombineReducers) | Direct dependency on Runtime |
| Delegate actions for cross-feature communication |
Guiding principle: Service is testable with `TestStore` and zero infrastructure. Every external interaction goes through a @Dependency client that can be swapped for a test double. If you need to import SwiftUI to make it compile, it doesn't belong here.
Forbidden attributes (invariants): @State, @Binding, @Observable, @ObservableObject, @EnvironmentObject, @Published, @StateObject
@Reducer
struct BookingFeature {
@ObservableState
struct State: Equatable {
var phase: BookingPhase = .selecting(selections: .init())
}
enum Action {
case confirmTapped
case delegate(Delegate)
@CasePathable enum Delegate: Equatable {
case bookingCompleted(Booking)
}
}
@Dependency(BookingDataClient.self) var bookingClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .confirmTapped:
guard case .readyToSubmit(let draft) = state.phase else { return .none }
return .run { send in
let booking = try await bookingClient.create(draft)
await send(.delegate(.bookingCompleted(booking)))
}
case .delegate:
return .none
}
}
}
}---
Runtime — The Wiring Layer
Depends on Service. Holds state management and orchestration that bridges Service logic to the UI layer.
| Contains | Does NOT contain |
|---|---|
@Observable state containers | @main app entry point (that's App Wiring) |
| State orchestration bridging Service → UI | Reusable UI components (that's UI) |
| Navigation coordination | Data type definitions (that's Types) |
| Store creation and scoping | Interface definitions (that's Config) |
Guiding principle: Runtime depends on Service and exposes state that UI can observe. It's the layer where business logic meets presentation concerns — but it doesn't render pixels itself.
---
UI — The Pixels
SwiftUI views and design system components that render state to the screen.
| Contains | Does NOT contain |
|---|---|
SwiftUI View structs reading from StoreOf<Feature> | @Reducer, business logic, state machines |
ViewModifiers, design system tokens, styles | Direct data access (import Repo) |
$store.send(.action) dispatches | Async effects or .run blocks |
| Design tokens, typography, spacing | Network calls, persistence, auth logic |
Previews (#Preview) | Dependency client definitions |
| Localization |
Guiding principle: UI is a pure function of state. Given a Store, it renders pixels. It sends actions back. It never fetches data, never decides business rules, never knows where data came from.
---
App Wiring — The Composition Root
The one place where all abstractions collapse into concrete reality.
| Contains | Does NOT contain |
|---|---|
@main app entry point | Reusable business logic (that's Service) |
DependencyRegistry.configure() — central DI wiring | Reusable UI components (that's UI) |
Per-domain Registration.swift files | Data type definitions (that's Types) |
| Live service instantiation (concrete Repo wiring) | Interface definitions (that's Config) |
UI test fake injection (--uitesting flags) |
Guiding principle: App Wiring is a leaf node — nothing imports it. It imports Runtime and Providers to compose the running application.
Litmus test: If removing this code means the app won't launch but all tests still pass, it's App Wiring.
---
Cross-Cutting Concerns
Capabilities that span all domains but aren't domains themselves. Two layers:
Utils — Pure Helpers
Dependencies: Foundation only.
Formatters, constants, accessibility ID enums, error types, logging abstractions, telemetry protocols. No domain knowledge, no side effects. Any layer can import Utils.
Providers — SDK Bridges
Dependencies: Utils, Types.
Bridges abstract protocols (defined in Utils or Types) to concrete external SDKs. Analytics initialization, telemetry configuration, UI test harness setup. Providers enter the business domain at Service and App Wiring — the only two layers that depend on them.
Decision tree
- Is it a pure formatter, constant, or error type? → Utils
- Does it wrap an external SDK (Sentry, Stripe, Firebase)? → Providers
- Is it business logic specific to a workflow? → It's a domain, not cross-cutting
---
Enforcement — Three Levels
Build-time enforcement is necessary but insufficient. It checks direct edges. A formal model checks reachability — whether ANY chain of legal imports creates a transitive path that violates an invariant. This catches gaming: thin wrappers, re-export modules, type tunneling through generics.
Level 1: Compile-time (direct edges)
SPM target boundaries — import Repo in a Config file produces a compiler error. Strongest for direct violations. Weakest for transitive ones.
Architecture spec — harness-spec.yml or dep4swift.json defines allowed_imports and forbidden_imports per layer. An audit tool reads the spec and flags every file that doesn't match.
Level 2: Formal verification (transitive reachability)
Alloy6 model (architecture.als) — Expresses invariants as assertions over the transitive closure of the import graph. Catches:
- Re-export gaming: Config imports Repo (allowed), re-exports types.
Service imports Config (allowed). Transitive path Service → Config → Repo violates serviceRepoWall even though no individual import is forbidden.
- Cross-domain leakage: Domain A's Service imports Domain B's Config
(allowed), which imports Domain B's Service (forbidden but discovered transitively). The crossDomainImportsAreClean invariant catches this.
- Completeness:
domainsAreCompleteverifies every domain has Config +
Service + UI — a property about the set of files, not any individual file.
Level 3: Architecture boundary tests (runtime verification)
Tests that verify import statements and type references don't cross forbidden boundaries. The safety net for violations that static analysis misses.
How they compose
| Level | Checks | Catches | Misses |
|---|---|---|---|
| Compile-time | Direct imports | import Repo in Service | Re-exports, transitive paths |
| Formal model | Transitive reachability | Gaming, completeness, isolation | Runtime-only violations |
| Boundary tests | Import + type refs | Concrete violations in test | Unbounded state space |
The formal model is the spec. The compile-time checks enforce the easy cases. The boundary tests catch what slips through. All three are needed.
Principles
Three essays inform this skill's architectural decisions. Understanding the why behind each principle helps you make better judgement calls when the rules don't perfectly fit.
---
"AI Is Forcing Us To Write Good Code" — Steve Krenzel
Krenzel's core insight: AI agents are only as effective as the environment you place them in. Good code structure isn't optional polish — it's the constraint system that makes agentic coding work.
The structure IS the guardrail
The only guardrails are the ones you set and enforce.
An agent with no constraints will produce unpredictable code. An agent operating in a well-structured codebase with enforced rules (types, tests, linting, dependency direction) is funneled toward correct solutions. The structure removes degrees of freedom until the only remaining path leads to the right answer.
For domain-architect: The layered architecture IS the guardrail. The architecture spec (harness-spec.yml / dep4swift.json) IS the enforcement. Once set up, every future agent session is constrained to produce code that respects domain boundaries and dependency direction.
Namespaces communicate intent
./billing/invoices/compute.ts communicates much more than./utils/helpers.ts, even if the code inside is identical.The file path tells the agent what it's looking at before reading a single line. Domain-first folder structure means every path carries context: Domains/Booking/Repo/ListingRepository.swift tells you the domain (booking), the layer (repo), and the role (data access for listings).
For domain-architect: When proposing folder structure, optimize for path readability. The path alone should answer: what domain? what layer? what responsibility?
Small files, full context
Prefer many small well-scoped files. Agents often summarize or
truncate large files when they pull them into their working set.
A 50-line file stays fully in the agent's context window. A 500-line god file gets truncated, and the agent misses critical details. Small, focused files are both better engineering and better agent UX.
For domain-architect: When you find god files (files mixing multiple layers), the migration plan should extract them into small, single- responsibility files BEFORE moving them.
End-to-end types with semantic names
If the model sees a type likeUserId,WorkspaceSlug, or
SignedWebhookPayload, it can immediately understand what kind ofthing it is dealing with.
Generic names like T, data, result force the agent to read surrounding context. Semantic names are self-documenting: BookingID can't be confused with SitterID even though both are strings.
For domain-architect: When classifying the Types layer, flag raw primitive usage (String, Int, [String: Any]) that should become semantic types. This is a restructuring opportunity.
---
"Parse, Don't Validate" — Alexis King
King's core insight: the difference between validation and parsing is entirely in whether you preserve the information learned. Parsing produces structured output; validation just returns "ok" and throws the knowledge away.
Parse at the boundary, use typed results everywhere
A parser is just a function that consumes less-structured input and
produces more-structured output.
Validation: func checkBooking(_ data: Data) throws — returns Void, knowledge discarded. Every downstream function must re-validate or trust blindly.
Parsing: func parseBooking(_ data: Data) throws -> Booking — returns a well-typed Booking. Every downstream function operates on the parsed type. No re-validation needed.
For domain-architect: The Repo layer IS the parsing boundary. Raw data enters (JSON, database rows, API responses), typed domain objects exit. Everything above Repo receives already-parsed data. This is the single most important architectural decision.
Make illegal states unrepresentable
Use a data structure that makes illegal states unrepresentable.
Instead of status: String (could be anything), use status: BookingStatus (enum with known cases). Instead of dates: [Date] (could be empty), use dates: DateInterval (always valid).
For domain-architect: When auditing the Types layer, look for:
- Booleans that should be enums (especially pairs of booleans)
- Optionals that are never nil in practice (strengthen the type)
- Strings used as identifiers (create newtype wrappers)
- Arrays that must be non-empty (use
NonEmpty<[T]>or validate at construction)
Push the burden of proof upward
Get your data into the most precise representation you need as
quickly as you can. Ideally, this should happen at the boundary
of your system, before any of the data is acted upon.
Don't scatter validation throughout the codebase. Parse once at the boundary, then trust the types. If you find guard statements or precondition checks deep in Service or Runtime code, that's a smell — the parsing should have happened earlier (in Repo).
For domain-architect: During layer classification, flag validation code (guard statements, assertions, error throws for "impossible" cases) that appears above the Repo layer. These are candidates for pushing down to the parsing boundary.
Avoid shotgun parsing
Shotgun parsing is a programming antipattern whereby parsing and
input-validating code is mixed with and spread across processing code.
When validation is scattered, you can never be sure all inputs were actually validated. Moving validation earlier introduces gaps, and removing "redundant" checks might break things. The system becomes fragile and unpredictable.
For domain-architect: If you find the same validation repeated in multiple places (e.g., nil-checking a value that "should never be nil"), that's shotgun parsing. The fix: parse it once at the boundary, use a non-optional type downstream.
---
The serviceRepoWall — Dependency Inversion at Architecture Scale
This is the most counterintuitive invariant and the one most likely to be questioned. It's formalized in architecture.als as an Alloy assertion.
The invariant
Service never compileDependsOn Repo. But Repo data does reach Service at runtime — through Config closures wired by Runtime.
Why it exists
If Service imports Repo, testing Service requires standing up the entire infrastructure: API clients, databases, keychain, network monitors. By inserting Config (interfaces) between Service and Repo (implementations), every Service test swaps Config clients for test doubles. Zero infrastructure. Tests run in milliseconds.
The formal guarantee
Import scanning checks: "Does this Service file import Repo?" → direct edge.
The Alloy model checks: "Is there ANY chain of imports through which a Service file can transitively reach a Repo file?" → reachability.
This catches the gaming scenario: someone creates a Config file that imports Repo and re-exports types. Every direct import is "legal." But the Alloy analyzer finds the transitive path Service → Config → Repo and reports a serviceRepoWall violation.
How data flows without compile deps
Service ──compileDependsOn──→ Config ──compileDependsOn──→ Types
↑ (at runtime)
Runtime ──wires──→ Config closures ←── Repo implementationsConfig declares: var fetch: @Sendable (ID) async throws -> Item Repo implements: func fetch(id:) async throws -> Item { apiClient.execute(...) } Runtime wires: client.fetch = { id in try await repo.fetch(id: id) }
Service calls client.fetch(id) — Repo data arrives, but Service never imported Repo. The compile graph and the data flow graph are different.
---
How the principles combine
| Principle | Architecture decision |
|---|---|
| Structure IS the guardrail (Krenzel) | Architecture spec enforces layers in CI |
| Namespaces communicate (Krenzel) | Domain-first folder structure |
| Small files (Krenzel) | Extract god files before restructuring |
| Semantic types (Krenzel) | Types layer uses BookingID not String |
| Parse at boundary (King) | Repo is the parsing layer |
| Illegal states unrepresentable (King) | Types layer uses enums and newtypes |
| Push proof upward (King) | All parsing lives in Repo, not Service/Runtime |
| No shotgun parsing (King) | One validation point per data source, in Repo |
Together: the architecture makes the right thing easy and the wrong thing hard. Types encode invariants. Repo enforces them at the boundary. The forward dependency chain (Types → Config → Repo → Service → Runtime → UI) ensures each layer only sees what it needs. The architecture spec enforces it all in CI. Agents operating in this environment are constrained toward correct code by the structure itself.
Related skills
FAQ
What does domain-architect do?
domain-architect: A skill for development. This provides functionality for development workflows.
When should I use domain-architect?
When you need to use domain-architect for development tasks, or when domain-architect: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
domain-architect.