The website you are reading this on is one Swift binary. It serves four public domains, decides which one you asked for from the Host header, and renders every page server-side. Behind that binary are seven local Swift packages and about 68,000 lines of Swift.
It did not start that way, and the reasons it ended up that way are the useful part. This is a concrete look at what modular packages buy you, what they do not, and the boundary problem they cannot solve for you.
The shape
There is one executable and six libraries, all local, all referenced by relative path. No versions, no registries, no submodules:
// Server/Package.swift
dependencies: [
.package(url: vaporURL, from: "4.99.0"),
.package(path: "../SharedWebKit"),
.package(path: "../SwiftlyDevelopedKit"),
.package(path: "../SwiftlyMarketingKit"),
.package(path: "../SwiftlyConsultingKit"),
.package(path: "../SwiftlyRecruitmentKit"),
.package(path: "../SwiftlyWorkspaceKit"),
]Measured today, the split looks like this — files, then lines of Swift:
- Server — 7 files, 1,659 lines. Entrypoint, configuration, routes, four middlewares. That is the whole executable.
- SharedWebKit — 35 files, 4,809 lines. Layouts, components, models, utilities. No web framework anywhere in it.
- SwiftlyDevelopedKit — 28 files, 5,236 lines. This site.
- SwiftlyMarketingKit — 19 files, 2,228 lines.
- SwiftlyConsultingKit — 30 files, 10,512 lines. Larger because it is localised.
- SwiftlyWorkspaceKit — 175 files, 22,346 lines. A full product site.
- SwiftlyRecruitmentKit — 157 files, 21,119 lines. The only module with a database in it: Fluent and Postgres.
The executable being the smallest module is not an accident, it is the design. Everything the Server package knows how to do is: detect a site, hand the request to a kit, and return what comes back.
The rule that makes it worth doing
If there is one thing to copy from this layout, it is the dependency list of the shared module. SharedWebKit — the package every site kit builds on — has no idea Vapor exists:
// SharedWebKit/Package.swift
dependencies: [
.package(url: elementaryURL, from: "0.6.0"),
.package(url: elementaryHTMXURL, from: "0.5.0"),
]Two HTML libraries, and nothing else. Not because Vapor is heavy, but because of what the absence enforces: no shared component can reach for a Request, read a header, touch a database or return a response. It takes values and returns HTML. That constraint is not documented in a style guide where people can forget it — it is a compiler error.
Everything downstream follows from that. The site kits depend on Vapor because they vend responses. RecruitmentKit is the only one that depends on Fluent and Postgres, so a bug in the recruitment portal's persistence layer cannot, structurally, be a bug in the marketing site. And when we eventually want to render a page from a command-line tool or a test harness with no server running, SharedWebKit already works there.
A dependency you cannot express is a mistake you cannot make. That is the whole return on modularising.
What splitting actually buys
Be precise about this, because the usual pitch is "faster builds" and that is only half true.
- Incremental build scope. Editing a file in SwiftlyWorkspaceKit rebuilds that module and relinks the executable. It does not recompile the other 45,000 lines. On the largest module here, that is the difference between an edit-run loop you tolerate and one you avoid.
- Parallelism. Independent modules compile concurrently. The site kits do not depend on each other, so they build side by side rather than in a queue.
- Enforced boundaries. Anything not marked public is invisible outside its module. You cannot accidentally couple two sites together, because there is nothing to reach for.
- Ownership. "Who broke the consulting site" has an answer that is a directory. Reviews get smaller and more focused because the diff lives in one module.
- Testability. Each package has its own test target and runs on its own. SharedWebKit's tests need no HTTP server because SharedWebKit has no HTTP.
And what it does not buy: a cold build is not faster. Splitting one target into seven does not remove work, it reorganises it, and it adds a small amount of manifest overhead. If your complaint is CI time from clean, modules are not the fix. If your complaint is the twelfth rebuild of the afternoon, they are.
The boundary modules cannot enforce
This is the part we learned the expensive way, and it is the reason this post exists rather than another checklist.
Module boundaries are a compile-time property. Routes are a runtime one. All four sites register their paths on the same Vapor router, because there is one application, so a path registered by the Workspace kit answers on every domain the binary serves. For a while, swiftly-developed.com/pricing/ returned 200 with Workspace's HTML on it — as did roughly thirty other paths.
A duplicated page is bad. A duplicated page carrying a canonical tag pointing at a different domain is worse: it tells Google the URL it just crawled is a copy of somebody else's, and the pages with no canonical at all are worse still, because then Google picks a winner itself. Perfect module separation, zero protection.
/// Rejects a Workspace-only path that did not
/// arrive on swiftly-workspace.com.
private func requireWorkspace(
_ req: Request
) throws {
guard detectSite(from: req) == .workspace
else {
throw Abort(.notFound)
}
}A plain 404, deliberately — not a redirect. These URLs never legitimately existed on the other three domains, so there is nothing to redirect to. The guard runs on every route that belongs to one site, and the comment above it in the repository is four times longer than the function, because the failure mode is invisible: the page renders fine, and rendering fine on the wrong domain is exactly the bug.
There is a second-order version of the same lesson in the same file. The function that decides which site a request belongs to is used both by the routes and by the 404 middleware. It is deliberately not private, with a comment saying so: one detection rule, not two that drift apart. Shared logic that must agree should be one function, even when copying six lines would be quicker.
Watch the public surface, not the file count
A module boundary is only as strong as its public keyword, and this is where our own layout is uneven. Counting public declarations per package: SharedWebKit exposes 159, which is fair enough — it is a component library and being used is its job. SwiftlyDevelopedKit exposes 59, and only five of those are the entry points the Server actually calls.
public enum SwiftlyDevelopedKit {
public static func homePage() -> HTMLResponse
public static func newsletterPage()
-> HTMLResponse
public static func blogHubPage()
-> HTMLResponse
public static func blogCategoryPage(
category: String
) -> HTMLResponse?
public static func blogPostPage(
slug: String
) -> HTMLResponse?
}SwiftlyWorkspaceKit exposes 427. That is not a crime, but it is a signal: a module with 427 public declarations has stopped being a module with an interface and started being a namespace. Nobody can change any of those 427 things without checking whether the Server depends on it, which is precisely the cost that modularising was supposed to remove.
The number worth tracking is public declarations per module, not files or lines. When it climbs, the fix is usually not another split — it is demoting most of them back to internal and keeping the handful that are genuinely the front door.
Mechanics that will bite you
- Product names are not package names. This repo's SwiftlyRecruitmentKit package vends a library called RecruitmentKit, so consumers write .product(name: "RecruitmentKit", package: "SwiftlyRecruitmentKit"). Get it wrong and the error message points at the wrong thing.
- Align your platforms. Every package here declares macOS 14. One package declaring something lower will fail to resolve against the others in a way that reads like a dependency error rather than a version mismatch.
- Keep swift-tools-version consistent. Mixed manifest versions across local packages is a source of behaviour differences that only appear on a different machine or a different CI image.
- No cycles. SwiftPM will refuse. If two modules want to import each other, the shared part is a third module — that is how SharedWebKit came to exist here.
- Access control is the API. Adding public is an interface decision, not a compilation fix. Get in the habit of asking whether the Server should be able to call this at all.
- Stale build plans. If you add a file and the compiler insists the symbol does not exist, delete .build/debug.yaml and the target's description.json and rebuild before you start doubting yourself.
When not to split
Most projects should not do this, or should do far less of it than they think.
- Under a few thousand lines. Folders are free, module boundaries are not. Use groups until the build actually annoys you.
- When the boundary is a guess. Extracting a module encodes a seam; if you are not confident it is the right seam, you have just made it expensive to move.
- One team, one deliverable, one deployment. The ownership argument is worth nothing and you are left with only the build argument.
- To make code reusable in the abstract. Reuse is a consequence of a boundary that was already correct — it is not a reason to create one.
- As a substitute for deleting code. Splitting a mess into seven packages gives you seven messes and a manifest to maintain.
The signal we use is change frequency. Split along the axis where things change independently — different products, different release cadences, different teams. If two parts always change together, a boundary between them will just be a boundary you keep crossing.
The extraction order
If you do have the case, do it leaf-first and let the compiler drive.
- Pull out the pure part first: models, formatting, anything with no framework in it. Its dependency list should be empty or nearly so, and that is what makes the rest possible.
- Then the shared presentation layer, keeping the framework out of it as hard as you can. Every import you refuse here is a coupling you will not have to unwind later.
- Then one feature module, chosen because it is the one you would most like to be able to test alone.
- Then the rest, one at a time, with the executable shrinking on each pass. If the executable is not getting smaller, you are moving code rather than splitting it.
- Recheck the public surface at the end. The first pass always over-exposes, because you were making it compile.
The short version
Modular packages are worth it when they let the compiler enforce a rule you would otherwise write in a document nobody reads. Our version of that rule is that the shared HTML layer cannot know what a Request is, and it has held for four sites and 68,000 lines.
They will not save you from runtime coupling — a shared router does not care how your source is organised — and they will not fix a cold build. Split where things change independently, keep the executable thin, and treat every public keyword as a promise you now have to keep.