How to Enforce Dependency Rules with SwiftLint Custom Rules
Domain Logic Should Be Framework-Agnostic
Imagine you’re reviewing code for your project, and you stumble upon this:
import UIKit
struct User {
var name: String
var age: Int
}
Looks innocent, right? But if this code lives in your domain layer — where core business logic is supposed to stay clean and independent — it’s a problem. By importing UIKit, the domain layer is now coupled to Apple’s UI framework, violating clean architecture principles. This tight coupling can make your code harder to test, less portable, and more prone to unintended side effects.
As your project grows, these accidental dependencies can sneak in more often, especially when multiple developers contribute. Catching these issues manually during code review is tedious and error-prone.
This is where SwiftLint comes to the rescue. SwiftLint is a powerful tool for enforcing style, convention, and even architectural rules in Swift code. By writing a custom SwiftLint rule, you can automatically detect and block imports of frameworks or dependencies in inappropriate places.
In this article, we’ll explore how to use SwiftLint to create a strict custom rule that ensures your domain layer stays dependency-free, keeping your codebase clean, testable, and maintainable.
Why Restrict Imports?
Restricting imports might sound extreme at first, but it’s a cornerstone of enforcing clean architecture. Let’s break down why it’s crucial.
Domain Logic Should Be Framework-Agnostic
The domain layer represents the core of your application’s business logic. This layer shouldn’t know anything about UI frameworks like UIKit, system libraries like Foundation, or even infrastructure-specific code like network handling. Why? Because:
- Portability: A clean domain layer can be reused across platforms (e.g., iOS, macOS, or even server-side Swift).
- Maintainability: Frameworks evolve, APIs get deprecated, and UI frameworks like UIKit may not even exist on platforms like macOS. Decoupling the domain logic ensures these changes don’t ripple through your core logic.
- Testability: Domain logic that’s independent of frameworks is easier to test. You don’t need to mock UIKit classes like UIImageor UIViewController.
The Risk of Accidental Dependencies
Accidental dependencies usually sneak in when:
- A developer imports a framework like UIKit for a convenience method or a constant.
- Copy-pasting code from another layer (like the UI layer) introduces unintended imports.
- Over time, these dependencies pile up, turning a clean domain layer into a tightly coupled mess.
For example:
import Foundation
import UIKit
struct User {
let name: String
let profilePicture: UIImage? // Why is UIKit in the domain layer?
}
Here, the UIImage property ties the domain logic to the UI framework. What happens if you later decide to share this domain layer with a macOS app? You’ll have to refactor or rewrite the User struct — something that could’ve been avoided by catching this issue earlier.
Automation with SwiftLint
Manually enforcing import rules during code reviews isn’t scalable, especially in teams. This is where SwiftLint shines. It’s a static analysis tool that helps you enforce coding standards and architectural rules. By writing a custom SwiftLint rule, you can:
- Automatically detect unwanted imports in specific layers (like your domain module).
- Enforce rules consistently across your team without relying solely on code reviews.
- Save time and ensure long-term code quality.
Setting Up SwiftLint
Installing SwiftLint: Brief guide to install SwiftLint using Homebrew.
brew install swiftlint
Integrating with Xcode: Add a “Run Script” phase to your build settings:
if [[ "$(uname -m)" == arm64 ]]
then
export PATH="/opt/homebrew/bin:$PATH"
fi
if command -v swiftlint >/dev/null 2>&1
then
swiftlint
else
echo "warning: `swiftlint` command not found - See https://github.com/realm/SwiftLint#installation for installation instructions."
fi
Writing a Custom SwiftLint Rule
Explain SwiftLint’s custom_rules feature.
Step-by-step guide to create a custom rule that prevents unwanted imports.
custom_rules:
no_import_in_domain:
name: "No Imports Allowed in Domain"
regex: '^import\s+\w+'
message: "Domain module should not contain any import statements."
severity: error
included:
- Sources/Domain
Regex Breakdown:
^import\s+\w+
ensures all import statements are matched.- included targets only the domain module to avoid false positives.
Advanced Use Cases
Restrict Specific Imports (e.g., UIKit, Foundation):
Example rule to allow imports but restrict specific ones:
custom_rules:
no_import_in_domain:
name: "No Restricted Imports in Domain"
regex: 'import\s+(UIKit|Foundation|CoreLocation)'
message: "Domain module should not import UIKit, Foundation, or infrastructure layers."
severity: error
included:
- Sources/Domain
Combining with Exclusions:
How to exclude specific directories or files:
excluded:
- Sources/Domain/Utilities
- Sources/Domain/Generated
Testing the Rule
Show how to validate the custom rule:
swiftlint lint --config .swiftlint.yml
Benefits of Using Custom Rules
- Saves code reviewers time by automating enforcement of architectural rules.
- Ensures architectural consistency and maintainability across the codebase.
- Reduces the risk of introducing dependencies in inappropriate layers.
What if we have multiple package and have their own rule ?should we check it manually ?
Each package can have its own .swiftlint.yml file if you need custom rules for specific packages. Here’s how to organize it:
Main App SwiftLint Configuration
- Create a .swiftlint.yml file in the root of your main app.
- This configuration will act as the default for the entire project unless overridden by specific packages.
Package-Specific SwiftLint Configurations
- In each local package, add a .swiftlint.yml file with rules specific to that package.
Tell SwiftLint to Use the Correct Config
By default, SwiftLint looks for a .swiftlint.yml file in the current directory or any parent directories. For packages that need custom rules, you can specify the config file explicitly.
Update the Run Script for Each Package
In your build process, specify the config for SwiftLint when linting a package:
swiftlint --config path/to/package/.swiftlint.yml
Example for Multiple Packages:
if which swiftlint >/dev/null; then
echo "Linting Main App..."
swiftlint --config .swiftlint.yml
echo "Linting Package A..."
swiftlint --config Packages/PackageA/.swiftlint.yml
echo "Linting Package B..."
swiftlint --config Packages/PackageB/.swiftlint.yml
else
echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint"
fi