What is a feature module, how do you draw module boundaries, and how does access control enforce them?

15.3.1 · L3 · Interview

Predict first

Can code outside a type read its private property?

struct Feature {
    private let secret = 1
    func reveal() -> Int { secret }
}
let f = Feature()
print(f.reveal(), f.secret)
  • Yes: 1 1
  • No: the property is private

Mental model. A feature module is a shop in a mall: the public sees the storefront and the door, the stockroom and staff areas stay private, and shops cooperate through the mall’s shared services, not by walking through each other’s back rooms.

A feature module packages one product area (checkout, profile, search) as its own target or Swift package with a deliberately small public interface: typically an entry point (CheckoutBuilder.makeView(dependencies:) or a route) and the models its callers need, while its views, view models, storage and helpers stay internal. The reasons are organisational as much as technical: teams own modules and can change their inside freely, incremental builds recompile only what changed, features can be built and previewed in a small demo app, and dependency rules become checkable by the build (a module that does not import another cannot call it). Typical layering: core/foundation modules (design system, networking, logging, utilities) at the bottom; domain/interface modules (protocols and models other features depend on) above; feature modules depending on those but not on each other; and the app target at the top as the composition root that wires them together. Features communicate through interfaces: a profile feature asks for “a way to open an order” (a protocol or closure supplied by the app) instead of importing the orders feature, which avoids cycles and keeps modules replaceable.

Access control is the enforcement tool. private and fileprivate hide details inside a type or file (accessing a private property from outside the type is a compile error); internal (the default) is visible within the module; package (Swift 5.9) shares between modules of the same package without making the API public; public is visible to other modules but cannot be subclassed or overridden outside it, while open allows that. A public type needs a public initialiser declared explicitly (the memberwise one is internal), which is a classic surprise when a feature returns its model to the app. Practical guidance: draw boundaries around things that change for the same reason and that teams own, not by layer alone; prefer fewer, cohesive modules to hundreds of micro-modules (each has build and cognitive cost); keep the public surface minimal and documented; avoid shared “Common” dumping grounds; use dependency injection across the boundary; and review the module graph for cycles and for features that depend on each other. Details of Swift packages, targets and build settings are covered in the modularization topic.

Access control is enforced by the compiler: a private member is not visible outside its declaration, which is how a module or type hides its inside.

Common traps

  • Making features import each other directly. It creates cycles and removes independence; communicate through interfaces supplied by the app.
  • Forgetting the public initialiser on a public type. Other modules cannot construct it; declare public init explicitly.

They might follow up with

  • What does package access give you? Visibility across modules of the same package without exposing the API publicly.
  • What is the app target’s role? The composition root: it builds the features and connects them.

Related questions

  • 15.2.2 How do you do dependency injection in Swift, and when do you need a container or framework?
  • 15.6.2 How do you run a shared mobile platform for many apps and teams: layering, ownership, versioning and…
  • 15.2.1 What is a coordinator or router, and how does it compare with SwiftUI’s NavigationStack for deciding who owns…

How do you migrate a large legacy codebase safely: the strangler… →