Don't Make It Public. Give It a Boundary

A module can already be right about what's internal and still be wrong about what's public.

A module can already be right about what’s internal and still be wrong about what’s public. In this article I explore what should go in the API for a pure SwiftUI module, the syntax to make it work, and the reasoning behind it.

This is the third part of a series about how to enhance validation in the agentic loop. See the accompanying code repo on Github here.

Faster UX review

I was working on the checkout flow. It was a funnel of the following screens: shopping cart, address, delivery and extras, payment method, card entry, processing, and confirmation. What I needed to iterate on back then was a new payment method. It was tedious to have to click through all the screens each time, so I decided to add a scenario in the micro-app for payment methods, giving direct access to each step.

The micro-app is a fully functional part of the application that is designed to be installable on a device. It loads a single feature module and presents a list of scenarios on launch. SwiftUI previews are helpful in the development process, tweaking the styling, etc., but it’s still faster to iterate over the screens in a micro-app than to launch the full app each time. And as a bonus, the UX designer could test it out on her device to validate the behavior and animations. It made the discussion of fine-tuning little details after review easier. This is the approach I have used for over a decade.

The ‘public’ Trap

Marking every view as public just so that the micro-app can present it may seem pragmatic, but it goes against the minimal API principle: a module should only expose the absolute minimum required for its integration and usage. A public accessor is forming a contractual obligation.

Exposing full visibility creates three core architectural issues:

1: Ambiguous Entrypoint: When every view is public, consumers cannot easily differentiate between the intended entry point versus an internal screen three steps into a flow.

2: Leaky State Contracts: Exposing programmatic navigation state instead of views doesn’t solve the issue if callers can construct arbitrary path sequences. Exposing detailed step data allows consumers to bypass domain rules — like jumping to payment without a validated address — while binding your public API to internal funnel order.

3: Expanded Blast Radius: Changes to internal screens can trigger rebuilding and re-testing of consumer modules.

Deep access into a module shouldn’t be the default. Reaching past the front door must be an explicit, narrow exception rather than a side effect of leaving everything unlocked.

The CI Needs API Discipline

If you invest in building an optimized, graph-aware CI pipeline before enforcing strict API boundaries, you are putting the cart before the horse.

Selective testing tools calculate target checksums to skip unchanged modules, but their efficiency is entirely bounded by your access control discipline. It works best when the API and implementation are defined as separate targets—see the references below about a separated interface.

In the opening example all the screens are made public. A minor UI tweak inside a feature touches the public boundary, forcing the build engine to invalidate the cache for every downstream module that imports the feature API. Selective testing promises drastic reductions in CI build times. A bloated API target can erase those savings, leaving you with full test runs.

No build tool can save a project from unnecessary test execution if developers treat the API target as a dumping ground for internal types.

An optimized CI doesn’t create decoupled architecture—it only rewards it. Enforcing what is allowed inside the API must come before optimizing the pipeline. There are ways of enforcing architectural rules as tests—a way to do that comes later in this series.

On a Need-to-Know Basis

Most developers think of access control in a module as a binary decision: it’s either internal or public. Unit tests typically use @testable import to get internal access, but this isn’t always available. An installable micro-app is usually compiled as a release build and cannot use @testable to reach inside a module.

Swift’s @_spi (System Programming Interface) attribute provides the missing middle ground. It is still public to the compiler but invisible to standard consumers:

@_spi(Internals) import Checkout

Without that explicit attribute at the import site, the code won’t show up in autocompletion, won’t appear in API diffs, and will throw a compile error if accessed.

You can get more specific about what the annotation is used for by defining different names for each usage. For example:

  • @_spi(Internals): Allows external test-support targets like CheckoutTesting to construct models and inject dependencies without exposing concrete initializers in the public production API.
  • @_spi(Scenarios): Allows a micro-app target to access screens that the normal user flows would never produce in a single step.

This approach lets you avoid defaulting to public when really this is not the real public API contract you want to support.

You might think that having this SPI option is still a risk. Nothing shown here stops a developer from inserting the@_spi(Internals) prefix anywhere they want, including a shipping application. There’s a clean way to enforce this, but again, that comes in a later article in this series.

Two Compositions, Not One

Many applications use a composition root pattern (see references below) to assemble a collection of modules into a working application: it is a single place where all the features and modules are wired together and is usually kept as close as possible to the application entry point.

As an application grows, this can become a bottleneck. If a new screen is added inside a feature, it should not affect the top-level composition root. In this case, it can be practical to introduce a second-level composition root in the feature. This way it can keep the public API smaller and delegate the assembly of all the screens of a feature to the feature module itself.

This can be expressed as the feature principle: the application should compose features, not construct them. For example the AppModel should be handed something that already knows how to produce a working CheckoutView, not a bag of CheckoutModel-construction knowledge duplicated at every call site that happens to need one.

What Belongs in an API?

The first thing that comes to mind is that public protocols and any data structs referenced in them must be in the API module. This is mostly based on the protocol-oriented programming (POP) design principle, where each class conforms to a protocol.

The standard rule is that a downstream module should depend on the abstraction, not the concrete implementation. This then forms a useful boundary that I mentioned above.

For a UIKit example, this can look like this:

protocol AccountService {
    func fetchProfile(completion: @escaping (Result<UserProfile, Error>) -> Void)
}

final class AccountViewController: UIViewController {
    private let service: AccountService

    init(service: AccountService) {
        self.service = service
        super.init(nibName: nil, bundle: nil)
    }
}

AccountViewController never names a concrete type—only the protocol. Production wires in DefaultAccountService; a test or a preview build wires in StubAccountService, and neither the view controller nor any of its call sites change.

An Explicit Dependencies Contract

The dependency inversion principle is the standard approach to designing modules such that depending on a stable abstraction promotes system adaptability and reduces risk from changes. (See Chapter 11, Clean Architecture—Robert C. Martin.)

You have a few options to implement this:

  • Constructor injection
  • Use a dependency injection library

There are several libraries for this, such as Swinject, Needle, Factory, and Resolver. For this project, I don’t think it’s necessary to use an external library for handling dependencies, but this is a decision you have to make for your own project.

Another common pattern is to define a single Dependencies container class and inject this into all modules. It’s easy, but it’s lazy. If there are many dependencies, it also makes it harder to test. It can be ambiguous what a module really needs and uses.

I recommend a different approach: define a dependencies struct such that every dependency is explicitly needed by that module.

For example, the Checkout module defines:

public struct CheckoutDependencies { ... }

More on this later.

The SwiftUI Associated Type Gotcha

Try the same move that just worked for AccountViewController and reach for a protocol on the SwiftUI side:

protocol CheckoutViewing: View {
 // …
}

This doesn’t do what you want it to, and the reason is specific, not vague Swift awkwardness. The SwiftUI View is declared as a protocol like this:

protocol View {
    associatedtype Body: View
    @ViewBuilder var body: Self.Body { get }
}

Body is an associated type—a placeholder that gets fixed to whatever concrete type body actually returns, decided separately for every conforming type. Declare your own protocol that requires a body, and you inherit the exact same problem one level up: Swift won’t let a protocol requirement return some View . The limitation is that opaque return types are only legal on a concrete declaration, never on a requirement. The only way to state the requirement at all is associatedtype Body: View; var body: Body { get }, which is just View’s own definition, once again.

Once your protocol has an associated type, any CheckoutViewing cannot be used in the same way any AccountService was two sections above. DefaultAccountService and StubAccountService could sit behind one existential because neither carried an associated type of its own. CheckoutView and a hypothetical FakeCheckoutView can’t: each has its own concrete Body, fixed by its own implementation, and there’s no single type left for the existential to actually hold.

This isn’t Swift being difficult about a legitimate design. “A type that renders as CheckoutView in production and as some other type in tests, interchangeably, behind one interface” was never a coherent idea to begin with—SwiftUI’s diffing engine has to know the real, concrete type it’s rendering to decide whether a view was updated or replaced and preserve @State and keep an animation continuous across a re-render. A protocol that hides that type doesn’t make the view more testable; it removes the one piece of information SwiftUI actually needs to do its job. some View can’t be an existential seam, and the fix isn’t a smarter protocol—it’s realizing the View was never where the seam belonged.

some View, Not AnyView

You might think that the easy way out of this is to use a type erasing AnyView. But there is a problem with this approach as well, and it’s considered a code smell.

It compiles, and it looks like the same fix that worked one section ago: hide the concrete type behind something more abstract. But AnyView erases exactly the static type information SwiftUI’s diffing engine relies on to tell whether a view was updated or replaced — a real cost paid at every AnyView boundary, not just the ones written for a test. I don’t use it at all in the sample project. See the references below for the mechanics and the measured performance cost in more depth.

The solution is to embrace the associated type restriction with this syntax:

@MainActor
final class DefaultCheckoutFactory {
    private let checkoutModel: CheckoutModel

    init(dependencies: CheckoutDependencies) {
        self.checkoutModel = CheckoutModel(dependencies: dependencies)
    }

    func makeCheckout() -> some View {
        CheckoutView(model: checkoutModel)
    }
}

some View was illegal on CheckoutViewProtocol’s body requirement two sections ago for the exact reason already covered — but makeCheckout() isn’t a protocol requirement here. It’s an ordinary method on an ordinary, concrete class. That’s the whole trick: the restriction is on protocols, not on functions, so a factory can return some View freely.

The caller — AppModel, RootView, the micro-app — gets back exactly what a protocol was supposed to give it: something that renders, with the concrete CheckoutView type nowhere in its own source. But unlike AnyView, some View isn’t erasing anything at runtime. The compiler resolves it once, at compile time, to the one concrete type makeCheckout()’s body actually returns — so SwiftUI’s diffing engine still has the exact static type it needs to decide whether the view was updated or replaced. Nothing was hidden from SwiftUI; only from the caller.

Whether DefaultCheckoutFactory conforms to a protocol of its own is a separate question from whether some View works here — it does either way, because the restriction two sections ago was never about factories, only about View itself. (It turns out there’s a good reason to give it one anyway — worth seeing once the full picture is on the page, a couple of sections from now.) The seam this whole section has been looking for was never at the View layer to begin with — it’s CheckoutDependencies, already covered above: swap what the factory is built with, not what type it hands back.

Being Environmentally Friendly

CheckoutDependencies is the right shape for what CheckoutModel actually needs — a repository that goes over the network, a store that touches UserDefaults, the things “An Explicit Dependencies Contract” argued belong behind an explicit, constructor-injected seam. Not everything a view reads is that kind of dependency, though. A currency formatter, a spacing token from the design system, a feature flag three subviews down that only one small piece of the payment step cares about — threading each of those through every intermediate view’s initializer, view after view, purely so the one leaf that needs it can reach it, is prop-drilling for its own sake. SwiftUI already has a mechanism for exactly this, and the trick is keeping it module-scoped instead of reaching for the app-wide version:

private struct CheckoutCurrencyFormatterKey: EnvironmentKey {
    static let defaultValue: NumberFormatter = .checkoutDefault
}

extension EnvironmentValues {
    var checkoutCurrencyFormatter: NumberFormatter {
    get { self[CheckoutCurrencyFormatterKey.self] }
    set { self[CheckoutCurrencyFormatterKey.self] = newValue }
    }
}

The factory sets it once, at the top of the module’s own view tree:

func makeCheckout() -> some View {
    CheckoutView(model: model)
      .environment(\.checkoutCurrencyFormatter, dependencies.currencyFormatter)
}

And any view nested arbitrarily deep inside Checkout reads it directly, with no initializer parameter carrying it down through every view in between:

struct PaymentMethodSelectionView: View {
 @Environment(\.checkoutCurrencyFormatter) private var formatter
 // …
}

CheckoutCurrencyFormatterKey and the checkoutCurrencyFormatter accessor are never marked public.

The alternative is the SwiftUI-flavoured version of a mistake this piece already named above: define one global AppDependencies environment value, set it at RootView, and let every view in every module read whatever piece of it they need. That’s “define a single class and inject this into all modules” from a few sections back, wearing .environment() instead of a constructor parameter — and it has the same two costs. A CheckoutView test now has to satisfy the whole AppDependencies shape just to exercise the one field it actually reads.

Walking Through The Solution

Put every piece from above together and here’s what Checkout actually looks like today.

CheckoutAPI holds the part that was never in question: the CheckoutRepository protocol, CheckoutDependencies, and the plain data types the two of them need. Nothing here changed through any of this. A different, equally reasonable codebase might inject NetworkClient instead and keep the repository itself as internal plumbing — this series draws the line one step further in specifically because Replay and the micro-app’s instant, in-memory scenarios both depend on the repository being the swappable part.

CheckoutAPI also holds CheckoutFactory, the protocol from a couple of sections back — one associated-type-bound makeCheckout() -> CheckoutContent requirement. Checkout itself provides the one conformance, DefaultCheckoutFactory, and for an ordinary import Checkout, it’s effectively the only type worth building anything against. CheckoutModel and CheckoutView are still there, still doing all the real work, but their designated initializers sit behind @_spi(Internals) instead of plain public. DefaultCheckoutFactory satisfies the protocol’s makeCheckout() with some View, and separately offers the fuller makeCheckout<PromotionBanner: View>(promotionBanner:) beyond it — the generic parameter a per-call promotion banner needs can’t be expressed as a single associated type, so every real caller today uses that fuller method directly on the concrete type, not through CheckoutFactory itself. It also maps the currency formatter injected from CheckoutDependencies to a module-scoped environment value.

Four real touch points, all in AppModel.swift and RootView.swift, show why the factory had to do more than hand back a view once:

storeModel.onAddToCart = { id, name, price, wantsGuarantee in
 checkoutFactory.addToCart(id: id, name: name, price: price, wantsGuarantee: wantsGuarantee)
}
checkoutFactory.onOrderPlaced = { order, guaranteeItems in /* … */ }
checkoutFactory.setSavedAddresses(accountModel.addresses.map { /* … */ })
model.checkoutFactory.itemCount // read in RootView, for the tab badge

These four methods form the interconnection between the other features without depending on another feature’s types. This follows ADR-0003’s existing Foundation-primitive-callback convention (already mandatory for Store/Search → Checkout) that is now applied symmetrically to AppModel’s own relationship with Checkout.

Each major decision in this series is recorded as an Architecture Decision Record (ADR). The repo this series draws from holds all of them in this folder: docs/adr/ — ADR-0003 above is one of fourteen, each a short, dated note on one specific design choice and the reasoning behind it. It is worth keeping open in a tab if a citation like this one raises a question this piece doesn’t have room to answer.

Next in the Promotions micro-app, thePromotionsAppstruct uses a stubbed checkout repository to inject a CheckoutFactory as a real instance is not required. It doesn’t have any scenarios to fabricate as it only displays some banners. The entire Checkout-specific footprint is:

private let checkoutFactory = DefaultCheckoutFactory(
 dependencies: CheckoutDependencies(repository: StubCheckoutRepository())
)
…
checkoutFactory.makeCheckout { PromotionBannerView(model: promotionsModel, sectionTitle: "You may also like") }

CheckoutApp’s scenario picker reaches @_spi(Scenarios) import Checkout to build a path no real user action produces in one step. It enables jumping straight to card entry with a saved address already in place. That’s a different need from the composition root’s, and it stays on its own door: the factory’s makeCheckout has no parameter for a mid-funnel path, because a production caller constructing one would have no principled way to promise the funnel’s ordering invariant still holds.

The fourth door, @_spi(Internals), has two real callers, for two different reasons. CheckoutTesting’s stub convenience init has to construct a real CheckoutModel with no view attached, and it’s a separate compiled target — not a test target — so @testable import was never on the table for it at all. And RootSnapshotTests, testing the composition root rather than Checkout on its own, needs to seed cart and savedAddresses directly before rendering — DefaultCheckoutFactory exposes exactly that as @_spi(Internals) public var model: CheckoutModel, deliberately not part of CheckoutFactory itself, so it’s reachable but never advertised as something an ordinary caller would want. Both are real import Checkout statements you can find in the repo today, both opting into the same named group for two distinct, legitimate reasons.

The payoff this whole piece has been arguing toward is the one a selective-testing system actually needed:

“Did Checkout’s public surface change” now means “did the funnel’s actual contract change” — not “did anything, anywhere in seven screens and a model, change.” A screen edit still rebuilds Checkout itself, correctly. It stops forcing every consumer downstream to rebuild and retest alongside it for a change none of them could have been affected by.

Where to Start

None of this is worth doing for its own sake. A single developer working alone doesn’t need a factory standing between AppModel and CheckoutView — there’s no second team to protect the boundary from, and the “protocol didn’t work, @_spi did” detour earlier in this piece solves a problem that only exists once a module has more than one legitimate reason to be reached into.

The threshold is concrete, not a feeling: once more than one team is shipping into the same app, “who’s allowed to construct this, and why” stops being obvious from context, and the cost this piece describes — a public surface nobody can read the intent of, a selective-CI system that can’t tell a real contract change from a screen tweak — starts being a problem you actually have, not one you might have someday.

Past that point I would recommend that you prioritize the cheapest step rather than tackling the whole system at once: look at what’s actually marked public in one module today and ask, for each one, who it’s really for. Most of it will already be right. The handful that aren’t are where a factory, an @_spigroup, or a CheckoutDependencies-shaped struct actually earns its keep — add each only once you can name the specific caller it’s protecting the rest of the module from.

A deep link is another caller trying to construct destination state from outside the module — the same question this whole piece has been answering, just arriving over a URL instead. It looks like one problem. It isn’t.

Next in this series: deep linking, and how this is really a two-part problem to solve.

Appendix

Appendix: Extending AGENTS.md for Module Boundaries

Article 2’s appendix started the pattern: rules an AI coding agent follows automatically instead of relearning by trial and error, one file, additive across the series. This is the next set.

## Module Public API
### Rule: a feature's public API is its repository protocol, its factory, and the data types those require - never the View, never a hand-written protocol for it
# Correct - the View is a leaf, hidden behind the factory
public protocol CheckoutFactory {
 associatedtype CheckoutContent: View
 func makeCheckout() -> CheckoutContent
}
# Never - View can't be the seam; a protocol that requires `body` inherits
# View's own associated-type problem one level up
protocol CheckoutViewProtocol: View { }
Why: `View` requires `associatedtype Body: View`. A protocol that requires a `body` inherits that same associated type, so `any CheckoutViewProtocol` can't unify two concrete conforming types the way a plain service protocol can. The seam was never at the View layer - it's the repository and the factory's dependencies.
### Rule: never reach for AnyView to make a view swappable for a test
# Correct
public func makeCheckout() -> some View { CheckoutView(model: checkoutModel) }
# Never
public func makeCheckout() -> AnyView { AnyView(CheckoutView(model: checkoutModel)) }
Why: `AnyView` erases exactly the static type information SwiftUI's diffing engine needs to tell an update from a replacement - a real cost paid at every boundary, for a problem `some View` on a concrete factory method already solves for free.
## @_spi Boundaries
### Rule: a model's designated initializer is @_spi(Internals), not plain public, once a factory exists
@_spi(Internals)
public init(dependencies: CheckoutDependencies) { /* … */ }
Why: nothing outside the module should construct `CheckoutModel` directly once `DefaultCheckoutFactory` is the intended entry point. Deleting the initializer would break `CheckoutTesting` and the composition-root snapshot tests, which have a legitimate reason to reach past the factory; `@_spi` keeps it reachable for them specifically, without advertising it to everyone else.
### Rule: the composition root never imports a feature module via @_spi
# Correct - AppModel and RootView only ever see the factory
import Checkout
# Never - reaching past the factory from the one place that should never need to
@_spi(Internals) import Checkout
Why: `@_spi(Internals)`/`@_spi(Scenarios)` exist for `CheckoutTesting` and the micro-app's scenario builder specifically - the factory's ordinary public API already covers what the composition root needs. An `@_spi` import from `AppModel` or `RootView` means something reached past the front door instead of through `DefaultCheckoutFactory`. This one is Harmonize-checkable directly, not just documentable - `Harmonize.productionCode().on("Shop").sources()`, asserting `!$0.hasAttribute(named: "_spi")` on every import - see the Harmonize article later in this series.
## Dependencies
### Rule: package a module's dependencies as one explicit struct, not a shared "AppDependencies" god object
# Correct
public struct CheckoutDependencies {
 public let repository: CheckoutRepository
 public let selectedAddressStore: SelectedAddressStore?
 public let currencyFormatter: NumberFormatter
}
# Never - one field per module, hiding which module actually needs what
public struct AppDependencies {
 public let checkoutRepository: CheckoutRepository
 public let accountRepository: AccountRepository
 // …
}
Why: a shared dependencies object is the same mistake as a global Environment value below, wearing a constructor argument instead - every module's test has to satisfy the whole shape to exercise the one field it actually needs, and any module can reach a dependency that was never meant for it.
### Rule: a SwiftUI Environment value defined for one module's internal use stays internal, never promoted to a shared key
# Correct
private struct CheckoutCurrencyFormatterKey: EnvironmentKey { /* … */ }
extension EnvironmentValues {
 var checkoutCurrencyFormatter: NumberFormatter { /* … */ } // not public
}
# Never - a global key any module can read or set
extension EnvironmentValues {
 public var appDependencies: AppDependencies { /* … */ }
}
Why: an `EnvironmentKey` fails silently to its `defaultValue` when nobody sets it - there's no compile error the way there is for a forgotten constructor argument. That's fine for a formatter; it's the wrong place for anything a module actually depends on to behave correctly, and a global key makes it reachable, and silently defaultable, from every module rather than just the one that owns it.

References

Composition Root Pattern
https://blog.ploeh.dk/2011/07/28/CompositionRoot/

Separated Interface from the implementation
https://martinfowler.com/eaaCatalog/separatedInterface.html

Avoiding SwiftUI’s AnyView
https://www.swiftbysundell.com/articles/avoiding-anyview-in-swiftui/

Why You Should Avoid Using AnyView in SwiftUI
https://wesleydegroot.nl/blog/Why-you-should-avoid-AnyView-in-SwiftUI

Type Erasure with AnyView in SwiftUI: Performance Costs
https://medium.com/@raajveer/type-erasure-with-anyview-in-swiftui-performance-costs-affd1fc404b9

Previous Articles in this Series

AI Writes Fast. Your Safety Net Needs to be Faster
/articles/2026/ai-writes-fast/

Don’t Trust Your Stub. Trust the Transcript.
/articles/2026/dont-trust-your-stub/

Books

Dependency Injection Principles, Practices, and Patterns — Mark Seemann & Steven van Deursen, Manning
P
atterns of Enterprise Application Architecture
— Martin Fowler

Further reading

Service Locator (Core J2EE Patterns — Alur, Crupi, Malks)
https://www.oracle.com/java/technologies/service-locator.html

Inversion of Control Containers and the Dependency Injection Pattern
https://martinfowler.com/articles/injection.html