As a freelancer I've worked on a lot of projects with legacy architecture, often a couple dozen SPM modules, Clean Architecture, several years of history, and it was usually rotting quietly in the same way...
A Presentation import that ended up in the data layer here, a view model reaching straight into a data toolkit instead of going through the domain there. Easy enough to spot in review on a small change, much less so once the codebase is huge.
What I actually wanted was simple: fail the build when a module imports across a boundary it isn't supposed to. I couldn't find something that did just that for Swift, so I ended up writing it.
It parses each file with SwiftSyntax (real syntax tree, no regex), works out which layer a file belongs to from a small config, and flags any import that points "outward". You describe the layers in a .solid.yml:
dependencyOrder: [Domain, Data, Presentation]
layers:
- name: Domain
paths: [Sources/Domain/**]
- name: Data
paths: [Sources/Data/**]
- name: Presentation
paths: [Sources/Presentation/**]
deny: [Data] # the UI goes through the Domain, not straight to Data
and a violation comes out looking like a normal compiler diagnostic:
Sources/Presentation/HomeView.swift:5: error: SolidLikeARock: layer 'Presentation' must not import 'Data'
A few things that came directly out of using it on a real codebase rather than designing it up front:
- init generates a starter config from your actual import graph, so you're not writing the whole thing by hand. There's a --freeze mode that adopts it on a legacy project with zero violations on day one and then only fails on new ones;
- basically baselining, which is the only realistic way I found to roll it out on an old codebase;
- It knows about TCA: an isolatePeers rule for "feature modules must not import each other", and init --tca to group a TCA project automatically;
- A graph command prints your layers as a Mermaid (or DOT) diagram straight from the imports, with rule-breaking edges in red. I mostly added it so the architecture diagram in the README stops lying.
It's deliberately small, SwiftSyntax + Yams, nothing else, and runs as a plain CLI, a SwiftPM build-tool plugin, a GitHub Action, or through Danger.
To be clear about what it isn't: it doesn't replace SwiftLint or Periphery, it's a different job. And it leans on the SwiftPM module graph, so plain Xcode targets without SPM modules aren't auto-discovered you'd write the config by hand there.
I'd genuinely like feedback on the config model, whether the layer/dependencyOrder/deny shape maps onto how you actually structure your apps, and whether the violation reasons read clearly.
Happy to answer anything.