Making the compiler enforce your architecture dependency rules: iOS

A dependency rule that lives in a wiki is already broken. We move it into Package.swift, access control, a CI script and Swift 6 isolation so that it fails the build, and we show the gap where SwiftPM quietly downgrades a violation to a warning.

It is a quarter to six on a Friday. The filter bug on the search screen has to make Monday's release, and teaching the repository about that filter is two days of work. The developer types import Networking, fires the request straight from the view model and puts a // TODO: temporary on top. The reviewer is tired, the diff is small, the approval lands. The Confluence page that says "Features must not import the data layer" is still there, and it is now wrong.

The thesis of this article: a dependency rule that lives only in a wiki page or in review comments is already broken, you just do not know in which file yet. A rule should fail the build. We covered the Gradle side in the Android article; here we do the same job on iOS with Swift Package Manager, access control, a CI script and Swift 6 concurrency. The running example is a hypothetical travel app called Voyage. Every code sample and error message below was compiled and run with the Swift 6.4 toolchain (Xcode 27.1) and SwiftLint 0.63; if you are on a different version, confirm the behaviour in your own toolchain.

A written rule breaks at the first deadline

A file we see often in large codebases looks like this. No bad intent, no inexperience, only a shortcut the compiler had no objection to.

// App target: Features/Search/SearchViewModel.swift
import SwiftUI

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var results: [BookingDTO] = []          // the data layer's DTO, all the way up on screen

    func search(_ query: String) async throws {
        // TODO: temporary, remove once the repository supports the filter
        var request = URLRequest(url: APIClient.shared.baseURL.appending(path: "bookings/search"))
        request.setValue(SessionStore.shared.token, forHTTPHeaderField: "Authorization")
        let (data, _) = try await URLSession.shared.data(for: request)
        results = try JSONDecoder().decode([BookingDTO].self, from: data)

        CheckoutCoordinator.shared.warmUpPaymentMethods()  // a direct call into another feature
    }
}

Three rules are broken here: the feature reaches the network layer directly, a DTO leaks into the domain, and one feature knows about another. In a single-target project all three are perfectly legal as far as the compiler is concerned. That TODO will probably be old enough to start primary school.

There is a ladder of enforcement. The higher you climb, the harder the rule is to bypass and the closer the moment of detection gets to the moment of typing.

Rung Where the rule lives When a violation is noticed How it gets bypassed
Convention Wiki, onboarding deck It is not By not reading
Review The reviewer's memory In the PR, if the reviewer is alert By opening the PR on a Friday evening
Lint .swiftlint.yml On save or in CI // swiftlint:disable
CI check A script in the repo In the PR, every time By editing the script or the baseline (visible in the diff)
Compiler The manifest and the types While typing By editing the manifest (visible in the diff)

Our position is simple: push every rule to the highest rung it can reach. Rules between modules go to the compiler and the CI script, rules inside a module go to lint. Review should keep only what a machine cannot express: whether a name means something, whether an abstraction earns its place.

If breaking a rule requires editing the file that defines the rule, the rule is being enforced.

The rule is Package.swift itself

In SwiftPM a target cannot see a module it does not list as a dependency in the manifest. If SearchFeature does not list Networking, the line import Networking is not supposed to compile. So the manifest is where the layering rule gets written, and we mirror the layers in the folder structure:

Sources/
  Kernel/          SharedKernel, DesignSystem
  Interfaces/      BookingInterface, PaymentInterface
  Infrastructure/  Networking, BookingData, PaymentData
  Features/        SearchFeature, CheckoutFeature
  App/             AppComposition

In a manifest full of plain .target(...) calls, seeing the rule means reading every dependencies array. We write one helper function per layer instead. The function signature becomes the rule: feature has no parameter through which you could name another feature or an infrastructure module.

// swift-tools-version: 6.2
import PackageDescription

let base: [SwiftSetting] = [.enableUpcomingFeature("InternalImportsByDefault")]

func deps(_ names: [String]) -> [Target.Dependency] { names.map { .byName(name: $0) } }

/// Kernel: depends on nothing.
func kernel(_ name: String) -> Target {
    .target(name: name, path: "Sources/Kernel/\(name)", swiftSettings: base)
}

/// Interface: sees SharedKernel only. No other parameter, because there is no other permission.
func interface(_ name: String) -> Target {
    .target(name: name, dependencies: ["SharedKernel"],
            path: "Sources/Interfaces/\(name)", swiftSettings: base)
}

/// Infrastructure: implements interfaces and may use other infrastructure modules.
func infrastructure(_ name: String, implements interfaces: [String] = [],
                    uses infra: [String] = [], products: [Target.Dependency] = []) -> Target {
    .target(name: name, dependencies: deps(["SharedKernel"] + interfaces + infra) + products,
            path: "Sources/Infrastructure/\(name)", swiftSettings: base)
}

/// Feature: kernel plus interfaces. Nowhere to write a feature or an infrastructure name.
func feature(_ name: String, uses interfaces: [String]) -> Target {
    .target(name: name, dependencies: deps(["SharedKernel", "DesignSystem"] + interfaces),
            path: "Sources/Features/\(name)",
            swiftSettings: base + [.defaultIsolation(MainActor.self)])
}

let package = Package(
    name: "Voyage",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [.library(name: "VoyageApp", targets: ["AppComposition"])],
    targets: [
        kernel("SharedKernel"),
        kernel("DesignSystem"),

        interface("BookingInterface"),
        interface("PaymentInterface"),

        infrastructure("Networking"),
        infrastructure("BookingData", implements: ["BookingInterface"], uses: ["Networking"]),
        infrastructure("PaymentData", implements: ["PaymentInterface"], uses: ["Networking"]),

        feature("SearchFeature", uses: ["BookingInterface"]),
        feature("CheckoutFeature", uses: ["BookingInterface", "PaymentInterface"]),

        // Composition root: the only target that sees everything.
        .target(name: "AppComposition",
                dependencies: ["SearchFeature", "CheckoutFeature", "BookingData", "PaymentData"],
                path: "Sources/App/AppComposition", swiftSettings: base),
    ]
)

Features that cannot see each other, with implementations parked behind interface modules, also cut dependency cycles off at the source; the details are in the circular dependencies article. If you generate the project with Tuist or XcodeGen the same idea applies: the layer functions live inside the project definition.

The manifest alone is not a wall

We have to be honest about one thing here, because until we tested it for this article we assumed the opposite. We added an undeclared import Networking to SearchFeature, ran a full build, and got this:

warning: 'SearchFeature' is missing a dependency on 'Networking' because dependency scan
of Swift module 'SearchFeature' discovered a dependency on 'Networking'
Build complete!

The build passed. The reason is mundane: AppComposition already depends on Networking, the module is produced in the same build, and it can be found in the shared products directory. Building only that target from a clean directory (swift build --target SearchFeature) failed on the same line with unable to resolve module dependency: 'Networking'; running the same command after a full build passed again. In another attempt, an undeclared import of a sibling feature did fail the full build. The outcome depends on build order and on what happens to be on disk, which is the last property you want in a rule.

We close the gap in two ways. Where Xcode does the building, the diagnostic can be promoted: passing DIAGNOSE_MISSING_TARGET_DEPENDENCIES=YES_ERROR to xcodebuild turned that warning into an error and broke the build. The setting is defined in Swift Build's source code, but we could not find it in Apple's build settings reference, so write a deliberate violation and try it on your Xcode version. The second is a check that does not depend on the toolchain: the CI script below compares the imports in the source files with what the manifest declares.

Access control is the other half of the architecture

The manifest says which module may see which. What a module can touch inside the module it sees is decided by access control, and most projects leave that half empty: everything is public, because "we might need it".

Our rule: an infrastructure module exposes a factory and nothing else. Implementation types stay internal, so nobody outside the composition root can even spell LiveBookingRepository.

// Sources/Infrastructure/BookingData/LiveBookingRepository.swift
public import BookingInterface
public import Foundation
import Networking          // with InternalImportsByDefault on, this line is internal
import SharedKernel

// internal: the name cannot be written outside this module.
struct LiveBookingRepository: BookingRepository {
    let client: HTTPClient

    func booking(id: BookingID) async throws -> Booking {
        let data = try await client.get("bookings/\(id.rawValue)")
        let dto = try JSONDecoder().decode(BookingDTO.self, from: data)
        return Booking(id: id, title: dto.title)   // the DTO never leaves this file
    }
}

struct BookingDTO: Decodable { let title: String }

// The only door this module opens.
public enum BookingDataAssembly {
    public static func makeRepository(baseURL: URL) -> any BookingRepository {
        LiveBookingRepository(client: HTTPClient(baseURL: baseURL))
    }
}

Imports have access levels too

SE-0409 (implemented in Swift 6.0) lets you put an access level on an import. internal import Networking means Networking types cannot appear in this module's public API. If someone adds this to BookingData:

public struct BookingDebugTools {
    public let client: HTTPClient
}

the compiler answers:

error: property cannot be declared public because its type uses an internal type
note: struct 'HTTPClient' is imported by this file as 'internal' from 'Networking'

One detail to get right: even in the Swift 6 language mode, imports are still public by default. The proposal defers the internal default to a future language mode and offers the InternalImportsByDefault upcoming feature until then. The manifest above turns it on for every target. The day you enable it you get a batch of errors, because every module that shows up in your public API now needs a public import. Those errors are useful: the top of each file becomes an explicit list of what that module leaks to its clients, and a public import Networking line stands out in review.

package: sharing without going public

SE-0386 (Swift 5.9) added the package access level: visible to modules in the same package, invisible outside it. SwiftPM passes the package identity to the compiler as -package-name for you. We use it in two places. The first is helpers and test fixtures shared by several modules that are not part of the app's API. The second, shown below, is restricting who can construct certain types.

Know its limit as well: package is a single ring. A feature in the same package sees the symbol too, so it cannot express a layering rule. The app target in your Xcode project, on the other hand, sits outside the package and has no access to package symbols.

A CI script for what the compiler cannot express

The layer functions make the rule readable, but nobody is forced to use them. If someone writes a plain .target(name: "CheckoutFeature", dependencies: [..., "SearchFeature"]) into the manifest, SwiftPM compiles it happily. There is no way to tell SwiftPM "no feature may depend on another feature". We can, however, read the manifest as data.

swift package describe --type json gives you name, path, type, sources, target_dependencies and (when present) product_dependencies for every target. swift package dump-package works as well, but it prints dependencies in the raw manifest shape, {"byName": ["SharedKernel", null]}; we prefer describe because its output is a flat list of names. The script reads the layer from the folder, checks three things and supports a baseline file:

// Usage: swift package describe --type json | swift Scripts/check-architecture.swift
import Foundation

struct PackageDescription: Decodable {
    struct Target: Decodable {
        let name: String
        let path: String
        let type: String
        let sources: [String]
        let target_dependencies: [String]?
        let product_dependencies: [String]?
    }
    let targets: [Target]
}

// The layer is read from the folder: Sources/<Layer>/<Target>
func layer(of target: PackageDescription.Target) -> String {
    target.path.split(separator: "/").dropFirst().first.map(String.init) ?? "?"
}

// Which layer may see which. This table is the whole rule.
let allowedLayers: [String: Set<String>] = [
    "Kernel": [],
    "Interfaces": ["Kernel"],
    "Infrastructure": ["Kernel", "Interfaces", "Infrastructure"],
    "Features": ["Kernel", "Interfaces"],
    "App": ["Kernel", "Interfaces", "Infrastructure", "Features"],
]
let layersAllowedThirdParty: Set<String> = ["Infrastructure", "App"]

// Extracts the module name from `import X`, `public import X`, `@testable import X`, `import struct X.Y`.
func importedModules(in source: String) -> Set<String> {
    let modifiers: Set<Substring> = ["public", "package", "internal", "fileprivate", "private"]
    let kinds: Set<Substring> = ["struct", "class", "enum", "protocol", "typealias", "func", "let", "var"]
    var result: Set<String> = []
    for line in source.split(separator: "\n") {
        var tokens = line.split(whereSeparator: \.isWhitespace)
            .drop { $0.hasPrefix("@") || modifiers.contains($0) }
        guard tokens.first == "import" else { continue }
        tokens = tokens.dropFirst()
        if let first = tokens.first, kinds.contains(first) { tokens = tokens.dropFirst() }
        if let module = tokens.first?.split(separator: ".").first { result.insert(String(module)) }
    }
    return result
}

let input = FileHandle.standardInput.readDataToEndOfFile()
let package = try JSONDecoder().decode(PackageDescription.self, from: input)
let targets = package.targets.filter { $0.type != "test" }
let byName = Dictionary(uniqueKeysWithValues: targets.map { ($0.name, $0) })
var violations: Set<String> = []

for target in targets {
    let from = layer(of: target)
    let declared = Set(target.target_dependencies ?? [])
    guard let allowed = allowedLayers[from] else {
        violations.insert("\(target.name): unknown layer '\(from)'")
        continue
    }
    // 1. A manifest dependency on a layer that is not allowed
    for dependency in declared.sorted() {
        guard let to = byName[dependency].map(layer(of:)), !allowed.contains(to) else { continue }
        violations.insert("\(target.name) -> \(dependency): \(from) must not depend on \(to)")
    }
    // 2. Third-party products only in the layers that are allowed to have them
    if !layersAllowedThirdParty.contains(from) {
        for product in target.product_dependencies ?? [] {
            violations.insert("\(target.name) -> \(product): \(from) must not use third-party products")
        }
    }
    // 3. A package module imported in source but not declared in the manifest
    for file in target.sources {
        let url = URL(fileURLWithPath: target.path).appendingPathComponent(file)
        guard let source = try? String(contentsOf: url, encoding: .utf8) else { continue }
        for module in importedModules(in: source) where byName[module] != nil
            && module != target.name && !declared.contains(module) {
            violations.insert("\(target.name) -> \(module): undeclared import (\(file))")
        }
    }
}

// Baseline: known violations. A new violation fails the build, and so does a fixed one left in the baseline.
let baselinePath = "architecture-baseline.txt"
let baseline = Set(((try? String(contentsOfFile: baselinePath, encoding: .utf8)) ?? "")
    .split(separator: "\n").map(String.init))

if CommandLine.arguments.contains("--update-baseline") {
    let text = violations.sorted().joined(separator: "\n") + "\n"
    try text.write(toFile: baselinePath, atomically: true, encoding: .utf8)
    print("Baseline updated: \(violations.count) violations")
    exit(0)
}

let new = violations.subtracting(baseline).sorted()
let fixed = baseline.subtracting(violations).sorted()
new.forEach { print("error: \($0)") }
fixed.forEach { print("error: fixed, remove from baseline: \($0)") }
print("Architecture check: \(targets.count) targets, \(new.count) new, \(baseline.intersection(violations).count) in baseline")
exit(new.isEmpty && fixed.isEmpty ? 0 : 1)

The line "Features": ["Kernel", "Interfaces"] in allowedLayers is the rule "no feature target depends on another feature target"; "Interfaces": ["Kernel"] is "interface targets depend on nothing but the shared kernel". Run against deliberate violations, the output is:

error: CheckoutFeature -> SearchFeature: Features must not depend on Features
error: SearchFeature -> Networking: undeclared import (SearchViewModel.swift)
Architecture check: 10 targets, 2 new, 0 in baseline

The script needs no build, so it goes first in CI:

set -euo pipefail
swift package describe --type json | swift Scripts/check-architecture.swift
swiftlint lint --quiet
swift test --package-path ArchitectureRules
xcodebuild build -scheme Voyage -destination 'generic/platform=iOS Simulator' \
  DIAGNOSE_MISSING_TARGET_DEPENDENCIES=YES_ERROR

The import scan is line based. It does not understand corner cases such as #if canImport(...) blocks or the word import at the start of a line inside a multi-line string. We saw no false positives in the sample project. If you hit one, encode it as an explicit exception in the script; put it in the baseline and it disappears into the same list as the real violations.

Rules that stay inside a module: SwiftLint and Harmonize

Not every rule lines up with a module boundary. In an app target that has not been split yet, "the Features folder must not import the data layer" is a job for lint, and so is "no URLSession inside a feature" in a project that has been split. SwiftLint's regex-based custom_rules section is enough for this:

custom_rules:
  feature_imports_infrastructure:
    included:
      - "Sources/Features/.*\\.swift"
    name: "Feature layer must not import infrastructure"
    regex: "^\\s*(@\\w+\\s+)?((public|package|internal|fileprivate|private)\\s+)?import\\s+(Networking|\\w+Data)\\b"
    message: "Features see Interface modules only. Implementations are wired in AppComposition."
    severity: error
  ui_framework_outside_ui:
    included:
      - "Sources/(Kernel/SharedKernel|Interfaces|Infrastructure)/.*\\.swift"
    name: "UI framework in a non-UI layer"
    regex: "^\\s*((public|package|internal|fileprivate|private)\\s+)?import\\s+(UIKit|SwiftUI)\\b"
    message: "Kernel, Interface and Infrastructure layers must not import UIKit or SwiftUI."
    severity: error
  url_session_in_feature:
    included:
      - "Sources/Features/.*\\.swift"
    name: "URLSession used directly in a feature"
    regex: "\\bURLSession\\b"
    match_kinds:
      - identifier
      - typeidentifier
    message: "Network calls belong to the repository. Do not use URLSession in a feature."
    severity: error

included and excluded are regexes over the file path, and match_kinds keeps the match out of comments and strings (the third rule does not trip on the word URLSession in a comment). If you use only_rules, you have to add the literal custom_rules to that list or your rules silently do nothing. The limit of a regex is plain: it looks at text and knows nothing about meaning. It will not catch a type hidden behind a typealias, and it will not catch // swiftlint:disable. We recommend grepping PR diffs for disable comments separately.

When you need more than text, the closest Swift counterpart to Konsist on Kotlin and ArchUnit on Java is Harmonize. You write rules as unit tests on top of SwiftSyntax. To be honest about maturity: Harmonize reached 1.0 in May 2026, the latest release as we write is 1.3.0 (September 2026), and the repository is active. It is not as established as ArchUnit, and parts of its API (the baseline parameter of assertEmpty, for example) are still marked experimental in the source. We keep the rules in a small separate package so that the swift-syntax dependency stays out of the app's graph:

// ArchitectureRules/Tests/ArchitectureRulesTests/LayerRules.swift
import Harmonize
import Testing

@Test
func featuresDoNotImportInfrastructure() {
    Harmonize.on("Sources/Features").sources()
        .withImport { $0 == "Networking" || $0.hasSuffix("Data") }
        .assertEmpty(message: "Features must not import infrastructure modules")
}

@Test
func viewModelsAreFinal() {
    Harmonize.productionCode().classes()
        .withSuffix("ViewModel")
        .assertTrue(message: "View models must be final") { $0.hasModifier(.final) }
}

@Test
func repositoryImplementationsStayInternal() {
    Harmonize.on("Sources/Infrastructure").structs()
        .withSuffix("Repository")
        .assertFalse(message: "Repository implementations must not be public") { $0.hasModifier(.public) }
}

Harmonize needs a .harmonize.yaml file (it may be empty) at the project root to locate that root. As for which tool to pick: SwiftLint is enough for import bans and you already have it installed. Add Harmonize when structural rules of the "types named like this must have that property" kind start to appear. Trust neither for rules between modules; that job belongs to the manifest and the script.

Beyond imports: types and isolation

Dependency direction is only the most visible kind of rule. "No ticket is issued for an order that has not been paid" is an architectural rule too, and the compiler can hold that one as well. The technique is to make the value that represents the illegal state impossible to construct:

// Sources/Kernel/SharedKernel
public struct PaymentReceipt: Sendable {
    public let reference: String
    public let amountInMinorUnits: Int

    // Only modules in the Voyage package can mint a receipt.
    package init(reference: String, amountInMinorUnits: Int) {
        self.reference = reference
        self.amountInMinorUnits = amountInMinorUnits
    }
}

public struct PaidOrder: Sendable {
    public let bookingID: BookingID
    public let receipt: PaymentReceipt

    // internal: the only way to get a PaidOrder is the confirm function below.
    init(bookingID: BookingID, receipt: PaymentReceipt) {
        self.bookingID = bookingID
        self.receipt = receipt
    }
}

public enum CheckoutError: Error { case amountMismatch }

public extension DraftOrder {
    func confirm(with receipt: PaymentReceipt) throws(CheckoutError) -> PaidOrder {
        guard receipt.amountInMinorUnits == amountInMinorUnits else { throw .amountMismatch }
        return PaidOrder(bookingID: bookingID, receipt: receipt)
    }
}

// Sources/Interfaces/BookingInterface
public protocol TicketIssuer: Sendable {
    func issueTicket(for order: PaidOrder) async throws
}

issueTicket takes a PaidOrder. A feature that tries to write PaidOrder(bookingID:receipt:) gets initializer is inaccessible due to 'internal' protection level; a module outside the package that tries to forge a PaymentReceipt gets the same error with 'package' protection level. The rule is now part of the signature, and no if check or comment is needed to remind anyone of it. We said above that the package ring is wide: a feature inside the package can still mint a receipt. A lint rule (PaymentReceipt( appears only inside PaymentData) closes that.

Swift 6 isolation is an architectural rule

"UI code runs on the main thread, repositories can be called from any thread" lived in the wiki for years. In the Swift 6 language mode that sentence compiles. The feature function in the manifest gives the target .defaultIsolation(MainActor.self) (SE-0466, Swift 6.2): everything in a feature module is on the main actor unless stated otherwise. Interface protocols are Sendable, so every implementation that crosses the boundary has to prove its thread safety to the compiler. If someone adds an unprotected cache to the data layer:

final class CachingBookingRepository: BookingRepository {
    private var cache: [BookingID: Booking] = [:]
    // ...
}
error: stored property 'cache' of 'Sendable'-conforming class 'CachingBookingRepository' is mutable

The fix is to make the type an actor or to put the state behind a lock; whichever you choose, the compiler made you choose. In a manifest whose tools version is 6.x, targets build in the Swift 6 language mode by default. Modules that are not ready can be left behind one by one with .swiftLanguageMode(.v5), and each of those lines is an entry in your baseline.

Rolling it out on an existing codebase

Building this from scratch is easy. The real work is getting there in a running project with hundreds of violations, without stopping anyone. The order we follow:

  1. Measure. Write the script and the lint rules and let them report only. Do not set a target before you have seen how many violations exist and which modules they cluster in.
  2. Take a baseline. Running the script with --update-baseline writes the current violations to architecture-baseline.txt. On the SwiftLint side the --write-baseline and --baseline options of swiftlint lint do the same, as does the baseline: parameter in Harmonize. Baseline files live in the repo.
  3. Block new ones. Make the check required. From that day the violation count can only go down. Because the script also fails when a fixed entry is left in the baseline, the list shrinks by itself and nobody can hide a new violation behind an old exemption.
  4. Review the baseline diff. A PR that adds a line to the baseline is openly declaring that it breaks a rule. Use CODEOWNERS to put the architecture owner on Package.swift, the script and the baseline files.
  5. Burn down the list. A few lines every sprint. Take the violations that need an interface extracted first, then the import clean-ups. Turn on InternalImportsByDefault and the Swift 6 language mode module by module, starting from the leaves.
  6. Run a canary. Every so often, open a PR with a deliberate violation. If it does not go red, a check has been quietly switched off somewhere.

Two numbers tell you it is working: the line count of the baseline file is falling, and PRs that break the architecture get fixed by their author before they turn into a review comment.

Where to stop

For a single team with a single target and fifteen screens, a five-layer manifest and a rules package is too much; two or three SwiftLint rules will do. What the number of modules costs you is a separate subject, covered in the cost of modularization article. Be modest with the number of rules too: do not write a rule you cannot enforce or cannot justify in one sentence. Five solid rules beat forty that nobody reads.

Checklist

  • Layers are defined in the folder structure and by helper functions in Package.swift; feature(...) has no parameter for a feature or infrastructure name.
  • You wrote an undeclared import on purpose and watched the build fail (DIAGNOSE_MISSING_TARGET_DEPENDENCIES=YES_ERROR or the script's import check).
  • Infrastructure modules expose only a factory; implementation types and DTOs are internal.
  • InternalImportsByDefault is on; public import lines are deliberate and few.
  • package access is used for sharing you do not want to make public.
  • The first CI step is the architecture script reading swift package describe --type json; feature-to-feature and interface-beyond-kernel dependencies fail the build.
  • Bans inside a module are SwiftLint custom_rules; structural rules, where needed, are Harmonize tests.
  • At least one business rule is expressed in types (such as the unconstructible PaidOrder).
  • Interface protocols are Sendable; feature targets are on the main actor; modules are in the Swift 6 language mode or in the baseline.
  • Baseline files are in the repo, only shrink, and their changes are reviewed through CODEOWNERS.
  • You opened a canary PR this quarter and it went red.
Level 12 completeYour progress is kept in this browser.
Up next: Making the compiler enforce your architecture dependency rules: Android

Need support on this?

Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.

Comments and questions

Disagree with something, or stuck on this in your own project? Write to us and let's talk.