Lesson 05 / 19
Modularization
Splitting the application into modules along package boundaries, the rule that feature modules depend only on the core module, and the blindness of a build-passes check to a boundary violation.
Published Updated
In this lesson
- Describes what splitting the application into modules along package boundaries changes
- Builds the module dependencies as a graph and checks whether they resolve and whether there is a cycle
- Measures that a check looking at whether the build passes stays blind to a module boundary violation
- Counts the dependents to rebuild when a module changes as a unit of work and ties it to a budget
Contents
In the fourth lesson we learned to pass a dependency from outside: objects know each other through a contract instead of building each other. The same idea holds one step higher. If the whole application sits in a single target, every file can see every file; no rule prevents a view from calling the network layer directly, or one feature from reaching into another feature’s internal types.
Modularization splits the code into packages and makes each package show only the surface it opens to the outside. Which module may depend on which is a rule. This lesson’s rule is this: feature modules depend only on the core module, and not on each other. The fault is the breaking of this rule: one feature module depends directly on another feature module. The core comes in verbatim, as in the earlier lessons.
// Separation.swift — classifies whether a check tells the correct version from the faulty one
struct Contrast<Output: CustomStringConvertible> {
let label: String
let correct: Output
let faulty: Output
let passesCorrect: Bool
let passesFaulty: Bool
var separation: String {
switch (passesCorrect, passesFaulty) {
case (true, false): return "separating"
case (true, true): return "blind"
case (false, false): return "noisy"
case (false, true): return "inverted"
}
}
func describe() -> String {
let onCorrect = passesCorrect ? "pass" : "fail"
let onFaulty = passesFaulty ? "pass" : "fail"
print("\(label) | correct=\(correct)/\(onCorrect) faulty=\(faulty)/\(onFaulty) -> \(separation)")
return separation
}
}
func contrast<Output>(_ label: String, correct: Output, faulty: Output, check: (Output) -> Bool) -> Contrast<Output> {
Contrast(label: label, correct: correct, faulty: faulty, passesCorrect: check(correct), passesFaulty: check(faulty))
}
func countSeparations(_ results: [String]) {
for name in ["separating", "blind", "noisy", "inverted"] {
print("\(name): \(results.filter { $0 == name }.count)")
}
}
The core is not redefined in this lesson either. The separation names are the same.
The Package Boundary
A module is a target of the package manager. The dependencies between targets are written in the package definition file, and the package manager reads that file to decide which target may import which. The items of a module left internal are not visible from other modules; those opened with public or open cross the boundary; between modules of the same package, package is enough as well.
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Shop",
targets: [
.target(name: "Core"),
.target(name: "Feed", dependencies: ["Core"]),
.target(name: "Profile", dependencies: ["Core"]),
.target(name: "Settings", dependencies: ["Core"]),
.target(name: "App", dependencies: ["Feed", "Profile", "Settings", "Core"]),
]
)
This package definition is not run; the package manager and the build are the platform’s tools, and the lesson claims no output for them. Its structure shows the rule: Feed, Profile and Settings depend only on Core, and App brings them all together. In the faulty version a single line changes.
.target(name: "Feed", dependencies: ["Core", "Profile"]),
Feed can now import Profile as well. The build does not reject this line: Profile exists as a target, and there is no cycle between them. This lesson’s question lies exactly here: a dependency the compiler accepts may not be a dependency the rule accepts.
The Dependency Graph
The model reduces the package definition to a graph: the name of each module and the list of modules it depends on. The graph answers three questions: does every dependency really point to a module, is there a cycle in the graph, and which modules have to be rebuilt when a module changes. The third question is used in the bounding measurement. Modules.swift is a helper file and prints no output.
// Modules.swift — module dependency graph
struct ModuleGraph {
let edges: [String: [String]]
var resolves: Bool {
edges.values.allSatisfy { deps in deps.allSatisfy { edges[$0] != nil } }
}
var isAcyclic: Bool {
func visit(_ name: String, _ seen: Set<String>) -> Bool {
if seen.contains(name) { return false }
return (edges[name] ?? []).allSatisfy { visit($0, seen.union([name])) }
}
return edges.keys.allSatisfy { visit($0, []) }
}
func dependents(of module: String) -> [String] {
var found: Set<String> = []
var queue = [module]
while let current = queue.popLast() {
for (name, deps) in edges where deps.contains(current) && !found.contains(name) {
found.insert(name)
queue.append(name)
}
}
return found.sorted()
}
}
func shopGraph(faulty: Bool) -> ModuleGraph {
ModuleGraph(edges: [
"core": [],
"feed": faulty ? ["core", "profile"] : ["core"],
"profile": ["core"],
"settings": ["core"],
"app": ["feed", "profile", "settings", "core"],
])
}
func featureViolations(_ graph: ModuleGraph) -> String {
let broken = ["feed", "profile", "settings"].flatMap { feature in
(graph.edges[feature] ?? []).filter { $0 != "core" }.map { "\(feature)->\($0)" }
}
return broken.isEmpty ? "none" : broken.joined(separator: ",")
}
resolves says that every dependency is found in the graph as a module, isAcyclic says there is no cycle, and featureViolations applies the rule: it lists every module a feature module depends on other than the core in the form feature->target, and returns none if there are none. Three checks are applied to the same two graphs; the first two look at what the build would look at, and the third looks at the rule.
// main.swift — the build or the rule
let good = shopGraph(faulty: false)
let bad = shopGraph(faulty: true)
let compiles = contrast("build-passes", correct: good.resolves, faulty: bad.resolves) { $0 }.describe()
let acyclic = contrast("no-cycle", correct: good.isAcyclic, faulty: bad.isAcyclic) { $0 }.describe()
let rule = contrast("features-depend-only-on-core", correct: featureViolations(good), faulty: featureViolations(bad)) { $0 == "none" }.describe()
countSeparations([compiles, acyclic, rule])
build-passes | correct=true/pass faulty=true/pass -> blind no-cycle | correct=true/pass faulty=true/pass -> blind features-depend-only-on-core | correct=none/pass faulty=feed->profile/fail -> separating separating: 1 blind: 2 noisy: 0 inverted: 0
The output was run three times in the sandbox on Swift 6.4 and is identical in all three. Of the three checks, one came out separating and two blind.
build-passes looks at whether the dependencies resolve. Because the feed -> profile link points to a real module, it resolves, and true comes out on both versions: the check is blind. no-cycle looks for a cycle. feed -> profile and profile -> core do not form a cycle, so this one also passes on both versions and is blind. Both are green and both are misleading: the fault is not in the validity of the graph but in the graph’s conformance to the rule.
features-depend-only-on-core applies the rule. In the correct version there is no violation and none comes out. In the faulty version feed->profile is listed and the check fails. This is separating, and it separates because it asks the rule’s own question instead of a question as broad as the build. The build says “does this work”; the rule says “is this allowed”. The value of modularization is in the second question.
Assumptions
AR1. The model reduces the package definition file to a dictionary. This lesson does not measure a real package manager’s target resolution, version constraints or platform conditions; the package definition stays KAYNAK.
AR2. The “build passes” check is modeled as the dependencies resolving and there being no cycle. A real build checks more than this (type errors, access control); those are outside this model.
AR3. Build time is not measured. Time depends on the machine, the cache and the load; in this lesson a unit of work is counted instead: the number of dependents of the changed module that have to be rebuilt.
The Bounding Measurement
The same rule also has a cost in rebuilding. When a module changes, every module that depends on it directly or indirectly is rebuilt. We set a budget for each changed module: as this lesson’s rule, when profile changes at most one module should be rebuilt, and when core changes at most four. The number is not time, it is a unit of work.
// main.swift — dependents to rebuild when a module changes
let good = shopGraph(faulty: false)
let bad = shopGraph(faulty: true)
let profileBudget = contrast("profile-budget", correct: good.dependents(of: "profile").count, faulty: bad.dependents(of: "profile").count) { $0 <= 1 }.describe()
let coreBudget = contrast("core-budget", correct: good.dependents(of: "core").count, faulty: bad.dependents(of: "core").count) { $0 <= 4 }.describe()
countSeparations([profileBudget, coreBudget])
profile-budget | correct=1/pass faulty=2/fail -> separating core-budget | correct=4/pass faulty=4/pass -> blind separating: 1 blind: 1 noisy: 0 inverted: 0
The output is identical across three runs. In the correct version, when profile changes only app is rebuilt. In the faulty version feed also depends on profile, so feed and app are rebuilt and the budget is exceeded. The check is separating. core, on the other hand, has four dependents in both versions: feed, profile, settings and app. The fault does not change the number of dependents of the core, so the same kind of budget check is blind for core. The same check can be separating on one module and blind on another. The cost of a boundary violation is paid only around the module that contains it.
Summary
- Modularization splits the code along package boundaries; which module may depend on which is written in the package definition.
- The lesson’s rule: feature modules depend only on the core module and not on each other.
- Checks that look at whether the build passes and whether there is no cycle are blind to a boundary violation; of three checks, two came out blind and one separating.
- The check that applies the rule finds the violation by name as
feed->profileand is separating. - The number of dependents to rebuild when a module changes is a unit of work and can be tied to a budget; it is separating on the
profilebudget and stays blind on thecorebudget. - This lesson’s separation: blind.
Next Step
The architecture section is finished: the calculation was separated from the screen, navigation from the views, state from scatter, the dependency from the inside, and the features from each other. The next topic is tests. In the first lesson, Unit Tests will test one of these pieces in a test target. There the fault will be at a threshold: the shipping fee will be calculated wrongly when the cart total is exactly at the threshold, and a test written with a value below the threshold will stay blind to this fault.
To keep your progress and take notes, Log in
My notes
Log in to take notes.