How to Structure a Swift Project for Long-Term Maintainability

How to Structure a Swift Project for Long-Term Maintainability

When a Swift project starts, the directory often contains a handful of files, a simple App target, and an implicit understanding among the team about where code belongs. Six months later, that same project can become a tangled web of view controllers, utility classes, and ambiguous naming conventions. The difference between a project that ages well and one that becomes unmanageable is rarely about the language itself—it is about the structural decisions made early and reinforced consistently. This article lays out a concrete approach to organizing Swift projects so they remain readable, testable, and adaptable over time.

Team collaborating on code at a workstation

Establish a Clear Directory Structure

The directory structure is the first thing any developer encounter when opening a project. If files are scattered or grouped arbitrarily, onboarding slows and refactoring becomes error-prone. A well-defined directory layout communicates intent: each folder tells you what kind of code lives inside and how it relates to the rest of the project.

Group by Feature vs. Group by Layer

Two dominant patterns exist for organizing code: grouping by architectural layer (e.g., Models, Views, ViewModels) or grouping by feature (e.g., Authentication, Profile, Checkout). In a small project, layer-based grouping works adequately. As the project grows, however, feature-based grouping scales better because it keeps related files together, reducing the mental overhead of switching between deeply nested folders to understand a single workflow.

A practical middle ground starts with top-level layer directories and migrates toward feature grouping once the project exceeds roughly 30 source files. The transition is straightforward: create feature subdirectories inside the existing layer folders, then move files. Commit early and often during this refactor to keep the history clean.

Recommended Directory Layout

Below is a layout that balances clarity and scalability for a medium-sized iOS application:

MyApp/
├── Sources/
│   ├── App/                  # App lifecycle, delegate
│   ├── Core/                 # Shared utilities, extensions
│   │   ├── Networking/
│   │   ├── Storage/
│   │   └── Extensions/
│   ├── Features/             # Feature modules
│   │   ├── Authentication/
│   │   │   ├── Views/
│   │   │   ├── ViewModels/
│   │   │   └── Models/
│   │   ├── Profile/
│   │   └── Checkout/
│   └── Resources/            # Assets, localizations
├── Tests/
│   ├── Features/
│   └── Core/
└── Package.swift             # If using SPM

This structure keeps the Core layer thin—only code shared across multiple features belongs there. Each feature folder is self-contained, which makes extracting a feature into a separate package straightforward when the need arises.

Enforce Modularity with Swift Packages

The Swift Package Manager is not only a dependency management tool; it is also an enforcement mechanism for modularity. By declaring explicit targets and their dependencies in a Package.swift manifest, you make the boundaries between modules visible and compile-time enforced.

Developer reviewing project architecture on dual monitors

When to Extract a Module

Not every feature needs its own package. Extract a module when one or more of the following conditions hold:

  • The feature is reused across multiple apps or targets (e.g., a shared authentication flow used by both the main app and an app extension).
  • The feature has a distinct release cadence or ownership (a team owns it independently).
  • The feature’s dependency graph differs significantly from the rest of the app (e.g., it depends on a heavy ML framework while the rest of the app does not).

If none of these conditions apply, keep the feature inside the main target under the Features/ directory. Premature extraction adds build complexity without proportional benefit.

Defining Module Boundaries

A well-defined module exposes a narrow public API and hides its implementation details. In Swift, this means marking internal symbols as internal (the default) and only promoting symbols to public when they are part of the module’s contract. Avoid the temptation to mark everything public for convenience—each public symbol is a commitment you must maintain.

Use @_implementationOnly import for dependencies that your module needs internally but should not leak through its public API. This prevents transitive dependency issues and keeps your module’s interface clean.

Adopt Consistent Naming Conventions

Naming consistency reduces cognitive load. When every developer on the team can predict what a file or type is called without searching, navigation time drops and misinterpretation becomes rare. Define and document conventions in a STYLEGUIDE.md file committed to the repository root.

Practical conventions to establish:

  • Files: One primary type per file. Name the file after the type (e.g., LoginViewController.swift, not Login.swift).
  • Protocols: Use the -able or -ing suffix for capability protocols (e.g., Cacheable, Authorizing). Use descriptive nouns for type protocols (e.g., NetworkSession).
  • Delegates: Name delegate protocols with the Delegate suffix and place them in the same file as the owning type.
  • Extensions: Create separate files for extensions on external types, named TypeName+Purpose.swift (e.g., URL+QueryItems.swift).

Enforce these conventions with SwiftLint. Add a .swiftlint.yml configuration to the project and integrate it into the CI pipeline so violations are caught before merge.

Implement Dependency Injection

Hard-coded dependencies make testing difficult and refactoring risky. When a view controller directly instantiates its network client, any change to that client ripples through every caller. Dependency injection decouples a type from the concrete implementations it relies on, allowing you to swap those implementations during testing or when requirements shift.

Protocol-Based DI

Swift’s protocol-oriented design makes lightweight dependency injection natural. Define a protocol for each dependency, then accept it through the initializer:

protocolUserDataProviding {
    func loadUser(id: String) async throws -> User
}

final class ProfileViewModel {
    private let dataProvider: UserDataProviding

    init(dataProvider: UserDataProviding) {
        self.dataProvider = dataProvider
    }
}

In production, you pass a real network-backed provider. In tests, you pass a stub or mock. No framework required, no reflection hacks—just protocols and initializer parameters.

Dependency Containers and Assemblers

For larger projects, managing dependencies across dozens of view models and services benefits from a lightweight container. This does not mean adopting a heavy framework. A simple assembler pattern suffices:

enum AppAssembler {
    static func makeProfileViewModel() -> ProfileViewModel {
        let networkClient = AppContainer.networkClient
        let provider = RemoteUserDataProvider(client: networkClient)
        return ProfileViewModel(dataProvider: provider)
    }
}

The assembler knows how to wire up each component. The rest of the app calls the assembler, never constructing dependencies manually. When you need to change how ProfileViewModel is assembled, you modify one location.

Write Tests That Support Refactoring

Tests are a structural asset. A well-tested project gives developers confidence to reorganize code because failures surface immediately. Poorly written tests, however, can work against you—fragile tests that assert on implementation details break with harmless refactors, training the team to ignore red builds.

Software engineer running unit tests in an IDE

Guidelines for maintainable test suites:

  • Test behavior, not implementation. Assert on outputs and state changes, not on which private methods were called or how many times a collaborator was invoked, unless that count is part of the contract.
  • Keep tests independent. Each test should set up its own state and tear it down. Shared mutable state between tests creates ordering dependencies and flakiness.
  • Organize tests to mirror source structure. If Features/Authentication/ contains view models and services, then Tests/Features/Authentication/ contains their corresponding test files. This symmetry makes finding tests intuitive.

Refer to Martin Fowler’s guidance on test doubles and their proper use to distinguish between stubs, mocks, spies, and fakes. Using the wrong type of double leads to coupling and brittle tests.

Document Architecture Decisions

Code tells you what the system does. Architecture decision records (ADRs) tell you why it does it that way. Without ADRs, new team members are left guessing why certain patterns were chosen—and may undo a deliberate choice under the assumption it was accidental.

An ADR need not be elaborate. A short markdown file per decision is sufficient:

# ADR 003: Feature-based directory grouping

## Status
Accepted

## Context
The project grew past 40 source files. Layer-based grouping caused frequent context switching across distant folders when working on a single feature.

## Decision
Reorganize directories under Features/ grouped by domain feature. Keep Core/ for cross-feature shared code only.

## Consequences
- Individual feature folders are self-contained and easier to extract later.
- Developers must resist adding shared code to Core/ without justification.

Store ADRs in a docs/adr/ directory at the repository root. Number them sequentially so they are easy to reference in comments and pull requests.

Conclusion

Structuring a Swift project for the long term is not a one-time setup—it is a discipline applied through every feature addition, every refactor, and every pull request review. A clear directory layout gives the team a shared mental model. Swift Package modules turn implicit boundaries into explicit, compiler-verified contracts. Consistent naming and enforced linting keep the codebase predictable. Dependency injection enables testing and swapping without cascading changes. Tests that assert on behavior rather than implementation protect the project during refactoring. Architecture decision records preserve the reasoning behind structural choices, preventing cyclical debates and accidental regressions.

Start with the structure outlined here, adapt it as your project’s domain demands, and—above all—document the deviations. A project that explains its own structure is a project that remains maintainable long after the original authors have moved on.

FAQ

When should I move from a single-target app to multiple Swift Package modules?

Consider extracting a module when a feature is shared across targets (e.g., the main app and a share extension), has separate ownership, or carries a distinct dependency graph. If none of these apply, keep the feature in the main target under a dedicated directory. Premature modularization adds build configuration overhead without proportional benefit.

Is a third-party DI framework necessary for Swift projects?

No. Swift’s protocol-oriented design makes lightweight, framework-free DI practical. Define protocols for dependencies, inject them through initializers, and wire components in simple assembler functions or a lightweight container. Reserve heavy DI frameworks for projects with hundreds of interdependent services where manual wiring becomes impractical.

How do I prevent the Core/Shared layer from becoming a dumping ground?

Apply a strict rule: code belongs in Core/ only if it is used by two or more feature modules. Require that any addition to Core/ be justified in the pull request description. Periodically audit Core/ for code used by only one feature and move it back. SwiftLint custom rules can also flag files in Core/ that import feature-specific symbols, signaling misplaced code.