Engineering Lab · personal R&D

My security gate was green on every pull request. It had never scanned a single line.

I found that by testing my own CI pipeline the way you would test a feature — end to end, with a real pull request, on purpose. It surfaced ten defects. Not one of them was visible from reading the code.

So that is what this lab is. Not a gallery of things that worked: a working codebase — a modular iOS app, a Kotlin Multiplatform shared layer, and the pipeline that releases both — documented the way I would hand it to a team that has to keep it running without me. The architecture, every decision and what it cost, and the failures I found in my own work.

10 defects found by running the pipeline
4 checks in place that were never doing their job
0 of them were visible from reading the code
3 platforms sharing one build machine
iOS Native Architecture · NurCore

1. Modular Architecture & Inverted Dependency

Four modules wired as App → NurUIKit → NurKit ← NurCommsKit. The last arrow points backwards on purpose — the data layer depends on the inner layer, never the reverse. Built with Swift 6.1, SwiftUI, XcodeGen and FactoryKit, targeting iOS 16.0+. Zero third-party HTTP libraries.

📐 Module Graph & Dependency Direction

Interactive Mermaid SVG
📱 NurCore Modular Architecture
flowchart TD subgraph APP["📱 App — Composition Root"] MAIN["@main NurCoreApp.swift
+ AppDelegate"] NAV["Navigation
AppRouteType · AppRouter · RouteView"] START["Startup/
6 LaunchStep bodies"] DI["AppDI.autoRegister()
the ONLY wiring point"] end subgraph NUIKIT["🎨 NurUIKit — Presentation"] PAGES["Pages/ — 9 screens
View + ViewModel pairs"] COMPS["Components/
NetworkErrorView, AsyncActionButtonView"] end subgraph NURKIT["🧠 NurKit — Domain (inner layer)"] UC["UseCases/ — 6 protocols
+ Native implementations"] REPO["Repositories/ — 7 PROTOCOLS
+ Unwired defaults that throw"] NET["Networking/ — APIService PROTOCOL
APIRouteModel · APIEnvironmentType"] MOD["Models/
NetworkErrorType · LoginSyncModel"] end subgraph COMMSKIT["📡 NurCommsKit — Data / I/O"] RIMPL["7 RepositoryImpl"] DSIMPL["6 DataSourceImpl"] CLIENT["URLSessionAPIService
actor · single HTTP client"] WIRE["Request / Response
internal — stops here"] STORE["KeychainTokenStorage
UserDefaultsPreferenceStore"] end APP --> NUIKIT APP --> NURKIT APP --> COMMSKIT NUIKIT --> NURKIT COMMSKIT -->|"implements protocols
DEPENDENCY INVERSION"| NURKIT PAGES --> UC UC --> REPO RIMPL -.implements.-> REPO CLIENT -.implements.-> NET RIMPL --> CLIENT RIMPL --> DSIMPL DSIMPL --> STORE CLIENT --> WIRE style NURKIT fill:#1a3a5c,color:#fff style COMMSKIT fill:#9a6700,color:#fff
↑ Why the arrow inverts
  • NurKit owns LoginRepository, APIService, TokenStorage as protocols.
  • NurCommsKit implements them — so it imports NurKit, not the other way round.
  • Consequence: NurKit compiles and its full test suite runs with NurCommsKit absent from the graph.
🔒 Enforced by the linker
  • Each module declares dependencies in its own config.yml; XcodeGen turns them into discrete targets.
  • Adding NurCommsKit to NurKit produces “Cycle in dependencies between targets” — verified by actually trying it.
  • Not oral convention: an illegal import fails the build.

🧩 Layer Explorer

Click a layer

Every feature is bound to the same chain. Pick a layer to see what it is allowed to touch — and what breaks when the rule is ignored.

📜 4-Module Responsibility Matrix

Module Owns (DO) Boundary (DON’T)
App NurCore Composition root, startup sequence, navigation, DI wiring in AppDI.autoRegister() No business rules. It assembles; it does not decide.
NurUIKit 9 screens (View + ViewModel), shared components Zero navigation knowledge — verified: no file references AppRouteType. Does not import NurCommsKit at all.
NurKit Business rules, all protocols, domain models, pure testable logic Zero HTTP implementation. Must never name a NurCommsKit type.
NurCommsKit URLSession client, Keychain, UserDefaults, wire contracts Must never import NurUIKit, and never be depended on by NurKit.

📷 Evidence — the modules are real build targets

Xcode screenshot

“Modular” is a claim that is cheap to make and easy to fake with folders. This is the actual Xcode project. Look at the TARGETS list: the three amber icons are three separate framework targets — each one compiles to its own binary, with its own Info.plist, its own dependency manifest and its own test bundle. They are modules, not sub-folders of one big codebase.

NurCore.xcodeproj — generated by XcodeGen, never committed. Scheme: NurCore Staging Debug.
🟡 3 amber icons

Three framework targets — three modules. NurKit, NurUIKit, NurCommsKit. Each links separately, so an import that is not declared in that module’s config.yml fails to compile. A folder cannot enforce that; a target can.

🧪 3 test bundles

NurKit_Tests, NurUIKit_Tests, NurCommsKit_Tests — one per module. NurKit_Tests runs without NurCommsKit in the graph at all, which is what proves the dependency inversion is real rather than aspirational.

📱 1 app target

NurCore is the only application target — the composition root. It is the single place that is allowed to see all three modules at once.

🎭 4 display names

Display Name resolves to four different values — NurCore_App_PD, _PR, _SD, _SR. That is the flavor matrix in §5, visible as a build-time fact rather than a runtime branch.

🛠 Left navigator

The Modules/ group mirrors the targets, and each module carries its own Info, Resources, Sources and Tests — the same shape four times over, not one shared pile.

📦 Dependencies

Factory 3.2.1 is the DI container; the Firebase group covers Crashlytics and Analytics. No HTTP library appears anywhere — the networking stack is URLSession only.

Minimum deployment target reads iOS 16.0, and the destination list includes iPad, Mac (Designed for iPad) and Apple Vision — which is why AppDeviceType has to resolve .vision behind an availability check rather than assuming iPhone.

⚠ Package.swift at the repo root is misleading. It lists the dependency direction reversed. That file is used by neither the local build nor CI — XcodeGen reads project.yml + each module’s config.yml. Read those instead.
Convention · Machine-checkable

2. Type Naming Standard

A type name is the only thing read before the file is opened — in a file list, in autocomplete, in grep output, in a PR diff. Three questions must be answerable without opening it: can the View touch this? is it a closed set of cases? is it data or behaviour?

The compiler guards correctness. Naming guards what the compiler cannot see.

🏷 Suffix Families

SuffixApplies toExample
ViewAnything conforming to SwiftUI.ViewIAuthUseCaseView, BuildInfoCardView
VMViewModel, one per screenIAuthUseCaseVM, RootVM
RequestSent to the serverLoginByEmailRequest
ResponseReceived from the serverLoginResponse, APIEnvelopeResponse
ModelEvery other data type — i.e. not a wire shapeLoginResultModel, DeviceSnapshotModel
TypeEvery enum, cases or namespace alikeNetworkErrorType, AppRouteType
(role)Behavioural types — name the verbLoginRepositoryImpl, NetworkLogRedactorType
🚫 No Util, no Helper
  • Both are the names reached for when a type’s responsibility is still unclear.
  • Sanctioning them creates an official dumping ground — once StringHelper exists, everything string-shaped lands in it.
  • If no verb fits the name, the type has not earned its existence yet.
⇆ One type, one direction
  • EmptyRequest is Encodable; EmptyResponse is Decodable.
  • Both are empty structs — still split, because one name for two directions can never state its direction at the call site.

⚙ Live Name Validator

Try it

The same rules the repository audit scripts enforce, running in your browser. Type a Swift type name and pick what it actually is.

Try:

🔍 Global Uniqueness — and why it is not about tidiness

Rename and grep operate on text, not on Swift’s understanding of scope. A name that is unique only to the compiler makes automated tooling dangerous. Every one of these actually happened in this repository:

NameCollides withConsequence
AppSwiftUI’s App protocolConformance had to be written SwiftUI.App — a qualifier that existed only because our name was bad
FirebaseConfigurationFirebaseCore’s own classEvery call had to be spelled FirebaseCore.FirebaseConfiguration
State@State, and the literal TextField("State", …)A global rename would rewrite a property wrapper and a UI string
PayloadGeneric parameter in APIEnvelopeResponse<Payload>The generic declaration itself would be corrupted
FixtureThe word “Fixture” in prose commentsProse silently turned into a type name — invisible to compiler and tests

Nesting is not protection. Unique to the compiler, not unique to grep — so these were lifted to top level with full names:

PathMonitorConnectivity.SnapshotNetworkPathSnapshotModel
RouteInventoryType.DecodedRouteInventoryDecodedModel
LoginResponse.SSOLoginSSOResponse
APIEnvelopeResponse.MetaAPIEnvelopeMetaResponse
StubConnectivity ×2StubConnectivityAPIService · StubConnectivityRouteTable
Two exceptions, both technical. Generated/ is rewritten by SwiftGen on every make update, so a rename there evaporates on the next command. CodingKeys is a Codable contract — renaming it kills encoder/decoder synthesis.
Runtime · One request end to end

3. Data Flow & Unified Error Handling

Swift Concurrency throughout — async throws, no Result, no completion handlers. The HTTP client is an actor. Exactly one error type is allowed to escape a UseCase.

🔄 Request-to-Response Sequence

Interactive Mermaid SVG
🔄 Tap → Screen, one login request
sequenceDiagram autonumber participant U as User participant V as View participant VM as ViewModel participant UC as UseCase participant R as RepositoryImpl participant API as URLSessionAPIService participant DS as DataSource U->>V: tap button V->>VM: await vm.execute() VM->>VM: performNetworkTask { } VM->>UC: try await useCase.loginByEmail() UC->>R: delegate (UseCase NEVER touches APIService) R->>API: request(.authLoginByEmail, body: LoginByEmailRequest) API->>API: connectivity guard → URL resolution → send API-->>R: LoginResponse decoded, or throw NetworkErrorType R->>R: map LoginResponse → LoginResultModel R->>DS: persist tokens / clear session DS-->>R: ok R-->>UC: LoginResultModel UC-->>VM: model VM->>VM: vm.resultText = "✅ …" VM-->>V: @Published changes V-->>U: screen updates, or error dialog + Retry

🛡 The API Wire Boundary

Server response shapes must never reach the screen. That is not enforced by review — it is enforced by the compiler, three ways at once:

1

NurUIKit does not import NurCommsKit at all — its config.yml lists only NurKit.

2

Every *Request / *Response is declared internal, invisible outside its module.

3

So a View literally cannot name LoginResponse. The build fails.

LoginResponse every field optional · mirrors JSON · internal
LoginRepositoryImpl maps it, resolving the optionals
LoginResultModel three non-optional fields · public in NurKit

⚠ NetworkErrorType — one error type, and it is a contract

Whatever the origin — transport, decoding, local storage — it is mapped to one of eight cases before it leaves the data layer. The consequence: a ViewModel catches exactly one type.

requestTimeoutConnection / server
unauthorizedConnection / server
noInternetConnection / server
serverErrorConnection / server
notFoundConnection / server
unknownConnection / server
technical(code:message:)API-technical
localError(message:)App-internal

NetworkErrorMapperType is deliberately not public — if presentation code ever wants to call it, that is a signal the mapping is happening in the wrong place.

Memory: vm., never self. inside performNetworkTask { vm in … }. The helper takes Self as a parameter precisely so [weak self] is written once in the helper instead of repeated in every ViewModel. Writing self reintroduces the retain cycle the helper exists to prevent.
Composition · FactoryKit

4. Dependency Injection & Type-Safe Routing

One wiring point for the whole app, and a routing scheme where forgetting a destination is a compile error rather than a blank screen.

🏭 Container.autoRegister() — the only place protocols meet implementations

FactoryKit calls it once, before the first resolution. There is no window where a service is built with a default and then swapped mid-flight. Because NurKit may not name a NurCommsKit type, every Factory<T> needs a default owned by NurKit itself — and the kind of default is a deliberate decision:

Default kindExamplesBehaviour when wiring is missing
Real but not persistent InMemoryPreferenceStore, InMemoryTokenStorage, AlwaysConnectedMonitor, EmptyRouteTableProvider Works; the symptom is visible; nothing is misleading
Throws, naming the cause UnwiredAPIService, all seven Unwired*Repository Fails loudly — the message says “register X in Container.autoRegister()”
The rule: an operation that has a correct answer while unwired gets a real default; an operation that does not, throws. Never return a fixture. A fixture turns a wiring bug into a plausible-looking screen.

🪨 Two injection styles — and why one is being retired

Preferred Constructor injection
init(xxxUseCase: any XxxUseCase
     = Container.shared.xxxUseCase()) {
    self.xxxUseCase = xxxUseCase
}

A test passes a stub straight in. No global state is touched, so nothing has to be cleaned up afterwards.

Legacy Property wrapper
@Injected(\.xxxUseCase)
private var xxxUseCase

A test must call Container.shared.x.register { … } and then .reset() — global state that has to be unwound, and a suite that must be serialised.

Both still exist in the codebase today. New ViewModels use constructor injection; the remaining property-wrapper ones are queued for migration.

🧭 AppRouteType + RouteView — exhaustiveness as a safety net

Navigation lives entirely in the App target. NurUIKit holds Views and ViewModels only — verified: no file in that module references AppRouteType. A screen does not know it has a route.

Chosen struct with a switch
switch route {
case .auth:      IAuthUseCaseView()
case .launchApp: ILaunchAppUseCaseView()
// forget one → compile error
}

Adding a case without handling it fails the build.

Rejected Registry dictionary
[AppRouteType: () -> View]

Trades a compile error for a blank screen at runtime whenever a route is left unregistered.

Tooling · Reproducible builds

5. Multi-Flavor Build Matrix & Design Decisions

The Xcode project is generated, not committed — so .xcodeproj merge conflicts cannot happen. Four flavors coexist side by side on one device.

📱 4-Flavor Matrix via xcconfig

FlavorAPI environmentNetwork loggingPurpose
Staging DebugStagingFullDay-to-day development
Staging ReleaseStagingFullQA builds with release optimisation
Production DebugProductionFullReproducing production-only defects
Production ReleaseProductionBasic — credentials redactedApp Store shipping build

Each module carries its own Configs/xcconfig/ set, so a flavor is a build-time fact rather than a runtime branch. Firebase reads a per-flavor GoogleService-Info plist and falls back with an explicit warning if the flavor-specific file is absent — a fallback, not a silent failure.

🔧 Deterministic Makefile

iosApp — daily workflow
make init      # check tools → gems → SwiftGen → XcodeGen → pods → SPM
make update    # regenerate resources + project after adding/moving files
make build     # compile the Production Debug scheme
make test      # full unit suite via the APPLICATION scheme (same as CI)
make lint      # SwiftLint
make open      # open the generated workspace
make update is not optional. XcodeGen is the source of truth for the build: a file that is not registered is not compiled, and its tests do not run — silently. Skipping it once produced a suite that reported “passing” while zero of its tests had executed.

🧠 Design Decisions — with the question to answer before reversing one

This table exists so a decision already weighed is not quietly undone by whoever arrives next.

DecisionReasonAnswer this first
Zero third-party HTTP librariesURLSession on iOS 16 already has async/await; JSONDecoder covers serialisation; URLComponents builds queriesWhich problem is still unsolved today?
async throws, not ResultAll six UseCases are already async throws and the helper already catches. Adding Result means a second error representation for the same thingWhy does a second error representation earn its keep?
URLSession injected, no default valueURLSession does not consult URLProtocol.registerClass, so a runtime tripwire cannot substitute. A required parameter makes the compiler guarantee tests never hit the real networkHow else do you prove tests cannot reach production?
Navigation entirely in the App targetNurUIKit is Views and ViewModels. AppRouter is neither — it is coordination state, and the NavigationStack consuming it lives in RootViewHow does NurUIKit stay reusable under a different navigation scheme?
Tokens in Keychain, metadata in UserDefaultsTokens deserve stricter storage than ordinary preferencesWhy would a token deserve demotion to UserDefaults?
ConnectivityMonitor.isConnected is synchronousThe guard before a request needs an answer now; an actor would force an await on the hottest pathHow many extra suspensions per request are acceptable?
Analytics neither throws nor asyncStatistics reporting must never fail or slow a business flowWhy should a user learn that analytics failed?
Static route table kept alongside remote syncGuarantees the app never loses every route — first install, offline, corrupted storageWhat is the fallback when the remote JSON is corrupt?
Errors shown as a dialog, not an inline cardAn inline card pushes the whole layout down as it appears, moving content out from under the user’s fingerHow do you keep layout stable with an inline card?
Layout · Grouped by feature

6. Directory Tree & Feature Trace

Folders are grouped by feature, not by file kind. Open Login/ anywhere and its contents are about login. Adding a feature means adding one folder, not touching six.

iosApp/ Modular Directory Layout

Click a folder to collapse
▼ 📱 App/ — Composition Root
Sources/NurCoreApp.swift@main entry point
Sources/AppDelegate.swift6-step startup sequence
Sources/Startup/LaunchStep bodies, testable in isolation
Sources/Navigation/AppRouteType · AppRouter · RouteView
Sources/App/AppDI.swiftthe only wiring point
config.ymlXcodeGen target + 4 schemes
▼ 🎨 NurUIKit/ — Presentation
Sources/Pages/<Screen>/one folder per screen: View + VM
Sources/Components/AsyncActionButtonView, ActionCardView
Sources/Components/NetworkError/View, modifier, presentation model
▼ 🧠 NurKit/ — Domain (inner layer)
Sources/UseCases/<Feature>/protocol + Native implementation
Sources/Repositories/<Feature>/protocol + Unwired default that throws
Sources/Networking/APIService protocol, APIRouteModel — zero HTTP
Sources/Models/NetworkErrorType, LoginSyncModel
Sources/DI/DomainDI + InfrastructureDI
▼ 📡 NurCommsKit/ — Data / I/O
Sources/Data/<Feature>/RepositoryImpl + DataSource + Request/Response
Sources/Data/Shared/URLSessionAPIService, NetworkErrorMapperType
Sources/Storage/KeychainTokenStorage, UserDefaultsPreferenceStore

🔎 One feature, traced end to end

Email login, from the screen to the wire:

LayerModuleFileTypes
ViewNurUIKitPages/IAuthUseCase/IAuthUseCaseView.swiftIAuthUseCaseView
ViewModelNurUIKitPages/IAuthUseCase/IAuthUseCaseVM.swiftIAuthUseCaseVM
UseCaseNurKitUseCases/AuthUseCase/AuthUseCase.swiftAuthUseCase · NativeAuthUseCase
RepositoryNurKitRepositories/Login/LoginRepository.swiftLoginRepository · LoginResultModel
ImplementationNurCommsKitData/Login/LoginRepositoryImpl.swiftLoginRepositoryImpl
DataSourceNurCommsKitData/Login/LoginDataSource.swiftLoginDataSource · LoginMetadataModel
API contractNurCommsKitData/Login/LoginRequests.swiftLoginByEmailRequest…
API contractNurCommsKitData/Login/LoginResponse.swiftLoginResponse · LoginSSOResponse

Note the two highlighted rows: those type names appear nowhere above them. That is not a coincidence — it is the API wire boundary, held by the compiler.

Questions about any of this? Let's talk.

Whether you want to discuss Swift Concurrency, KMP tradeoffs, or CI/CD runner architecture, I'd love to connect.

Get in Touch → 📄 Download CV (PDF) ↓