Just Say No to DDD

#code

Domain-driven Design (DDD) is a software design approach that attempts to structure code to "match a domain". It promises "adaptable", "scalable", and "maintainable" software by dividing apps into "bounded contexts" and by "aligning code" with "business needs". It sounds great, in theory. But in reality, it's a trap... especially when applied to SwiftUI.

Code as a Liability

Code isn't an asset. It's a liability. Every line of code is another line to test, debug, and maintain. And every line of code introduces new opportunities for new bugs. Despite the promises, DDD increases this liability by adding layers of unnecessary code and generally making a mess of everything.

Broken Promises

DDD might be okay for massive, legacy, enterprise Java systems from the early aughts. But it's overkill for modern SwiftUI apps. Positioned as a way to make code adaptable, scalable, and maintainable, DDD simply fails on all three fronts:

Code Comparison

Instead of me just telling you that DDD is bad, let me show you... Consider this vanilla implementation for fetching and displaying Todos using the jsonplaceholder API:

import SwiftUI

// 1. Model

struct Todo: Codable, Identifiable {
    let id: Int
    let title: String
    let completed: Bool
}

// 2. Client (fetch todos)

struct TodoClient {
    var fetchTodos: @Sendable () async throws -> [Todo]

    static let live = Self(
        fetchTodos: {
            guard let url = URL(string: "https://jsonplaceholder.typicode.com/todos/") else { throw URLError(.badURL) }
            let (data, _) = try await URLSession.shared.data(from: url)
            let todos = try JSONDecoder().decode([Todo].self, from: data)
            return todos
        }
    )
}

// 3. View (display todos, handle errors)

struct TodoView: View {
    @State var todos: [Todo] = []
    @State var errorMessage: String? = nil
    private let client: TodoClient = .live

    var body: some View {
        List {
            ForEach(todos) { todo in
                HStack {
                    Image(systemName: todo.completed ? "checkmark.circle": "circle")
                        .foregroundStyle(todo.completed ? .green : .gray)
                    Text("\(todo.title)")
                }
            }
        }
        .navigationTitle("Todo List")
        .task { await fetch() }
        .alert("Error", isPresented: .constant(errorMessage != nil)) {
            Button("OK", role: .cancel) { errorMessage = nil }
        } message: {
            Text(errorMessage ?? "No Error")
        }
    }

    func fetch() async {
        do {
            todos = try await client.fetchTodos()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

#Preview {
    NavigationStack {
        TodoView()
    }
}

Full DDD

And here's what the same code might look like when you go "full DDD" (based on this popular Medium article). Honestly, don't even try to read or understand all of it. Just notice that it's wayyyyyy more code:

import SwiftUI

// 1. Models
// TodoDTO: The raw data from the API
// TodoDMO: A "cleaned-up" version of the data for actual app use

struct TodoDTO: Codable {
    let id: Int
    let title: String
    let completed: Bool

    func toDomainModelObject() -> TodoDMO {
        TodoDMO(id: id, label: title, isCompleted: completed)
    }
}

struct TodoDMO: Identifiable {
    let id: Int
    let label: String
    let isCompleted: Bool
}

// 2. "Data Source"
// Fetches TodoDTOs from an API, making things more complicated than they need to be

protocol TodoDataSource {
    func fetchTodos() async throws -> [TodoDTO]
}

enum TodoDataSourceError: Error {
    case badUrl
    case requestError
    case decodingError
    case statusNotOK
}

struct TodoDataSourceImplementation: TodoDataSource {
    func fetchTodos() async throws -> [TodoDTO] {
        let urlString = "https://jsonplaceholder.typicode.com/todos/"
        guard let url = URL(string: urlString) else {
            throw TodoDataSourceError.badUrl
        }
        guard let (data, response) = try? await URLSession.shared.data(from: url) else {
            throw TodoDataSourceError.requestError
        }
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw TodoDataSourceError.statusNotOK
        }
        guard let todos = try? JSONDecoder().decode([TodoDTO].self, from: data) else {
            throw TodoDataSourceError.decodingError
        }
        return todos
    }
}

// 3. "Repository"
// Converts TodoDTOs to TodoDMOs while adding an extra layer for no obvious reason

protocol TodoRepository {
    func fetchTodos() async throws -> [TodoDMO]
}

enum TodoRepositoryError: Error {
    case decodingError
    case networkError
}

struct TodoRepositoryImplementation: TodoRepository {
    private let dataSource: TodoDataSource

    init(dataSource: TodoDataSource) {
        self.dataSource = dataSource
    }

    func fetchTodos() async throws -> [TodoDMO] {
        do {
            let todosDTO = try await dataSource.fetchTodos()
            return todosDTO.map { $0.toDomainModelObject() }
        } catch TodoDataSourceError.decodingError {
            throw TodoRepositoryError.decodingError
        } catch {
            throw TodoRepositoryError.networkError
        }
    }
}

// 4. "UseCase"
// A glorified wrapper to call the repository and handle errors in a slightly different way

protocol TodoGetUseCase {
    func execute() async -> Result<[TodoDMO], TodoGetUseCaseError>
}

enum TodoGetUseCaseError: Error {
    case networkError
    case decodingError
}

struct TodoGetUseCaseImplementation: TodoGetUseCase {
    private let repository: TodoRepository

    init(repository: TodoRepository) {
        self.repository = repository
    }

    func execute() async -> Result<[TodoDMO], TodoGetUseCaseError> {
        do {
            let todos = try await repository.fetchTodos()
            return .success(todos)
        } catch {
            return .failure(error is TodoRepositoryError ? .decodingError : .networkError)
        }
    }
}

// 5. "ViewModel"
// Mediates between the UseCase and the View, because we always need more layers!

@Observable
class TodoListViewModel {
    var todos: [TodoDMO] = []
    var errorMessage = ""
    var hasError = false

    private let useCase: TodoGetUseCase

    init(useCase: TodoGetUseCase) {
        self.useCase = useCase
    }

    func loadTodos() async {
        errorMessage = ""
        hasError = false
        let result = await useCase.execute()
        switch result {
        case .success(let todos):
            self.todos = todos
        case .failure(let error):
            self.todos = []
            errorMessage = error.localizedDescription
            hasError = true
        }
    }
}

// 6. View
// Finally, something useful: displays todos and errors, cleaning up after all the other layers

struct DDDTodoView: View {
    @Bindable var viewModel: TodoListViewModel

    init(viewModel: TodoListViewModel) {
        self.viewModel = viewModel
    }

    var body: some View {
        List {
            ForEach(viewModel.todos) { todo in
                HStack {
                    Image(systemName: todo.isCompleted ? "checkmark.circle" : "circle")
                        .foregroundColor(todo.isCompleted ? .green : .gray)
                    Text(todo.label)
                }
            }
        }
        .navigationTitle("Todo List")
        .task { await viewModel.loadTodos() }
        .alert("Error", isPresented: $viewModel.hasError) {
            Button("OK", role: .cancel) {}
        } message: {
            Text(viewModel.errorMessage)
        }
    }
}

#Preview {
    @Previewable @State var viewModel = TodoListViewModel(
        useCase: TodoGetUseCaseImplementation(
            repository: TodoRepositoryImplementation(
                dataSource: TodoDataSourceImplementation()
            )
        ))
    NavigationView {
        DDDTodoView(viewModel: viewModel)
    }
}

Both implementations do the exact same thing! Seriously, copy-and-paste both blocks into Xcode and see for yourself! All the extra layers—the repository, use case, and view model—add bloat without benefit. But what about testing? Okay. Fair. To test the vanilla version, just create a TodoClient.mock extension and inject it where needed. Done.

HackerNews

If the code comparison isn't enough to convince you that DDD is unnecessary and actively counterproductive, these collected quotes from various HackerNews threads should:

"In my 22 years of career in software starting as a developer, I have seen DDD used only one time successfully and appropriately. All other attempts were half-baked and over-engineered messes. The problem was that over several years, it was hard to get new hires up to speed to maintain the complex code and test it."

"DDD strikes me as the software version of Agile sometimes. The ideas and philosophy behind are good, but end up being taken as a silver bullet. I’ve seen DDD being branded together with CQRS as 'Clean Architecture,' which in reality turns out to be a mess of layers and separations."

"We use DDD at the current company I work in, and to be honest, I detest it so much that sometimes it makes me wonder if I even want to continue in the programming space (been at it for 20 years). Don’t get me wrong, DDD has meaning and purpose, but some companies are applying it as a badge to be obtained instead of pondering the question: Do you really need to rewrite everything following DDD?"

"DDD seems to be one of those things where 'what it’s meant to mean' is very different from 'how it’s actually practiced.' Reading about what it’s meant to mean, it seems pretty common sense. As often implemented, however, it seems to lead to a lot of accidental complexity and a bunch of dubious usefulness abstraction layers."

"In my experience, the most useful part of DDD is to have a common vocabulary for your projects—developers and end users should have some common terminology. For complex business domains, it is good to have a glossary and for your code to always use those words in the same way the business uses them. Other than that, most DDD concepts are a bit dated, and really oriented around JAVA/C# in the early 2000s."

"All principles help you to model your software in a way that’s highly cohesive and loosely coupled. My suggestion: Stay away from all these design patterns. In my experience, they lead to overly complex code because of all the structure. The best structure is the most simple."

"I thought the DDD hype had died down a bit, but I guess not. Eric Evans (inventor of DDD) has said in recent years that, unfortunately, once a team is big enough, all the invisible conceptual boundaries between domains blur and disappear. People do NOT have the discipline to do DDD correctly."

"When you start thinking in abstractions and create abstractions in your code, either top-down or bottom-up, you will end up producing a good software design. This, to me, is the most untrue statement you could make about software. Abstractions introduce more complexity. The more complex, the less stable and maintainable your software becomes."

"Domain-Driven Design is a poison. The book is one of the most poorly written technical books out there. There are a handful of good ideas buried in the 1,000-page unedited verbose rambling slog. The whole book should only have been 10 pages. Besides Eric Evans’s inability to write, the poison of DDD comes from locking in businesses/domain concepts into your core technology, making them inflexible and making it difficult for the business to iterate on new ideas."

"The biggest flaw of DDD I’ve run into is there’s no emphasis on when not to use it. There’s no mention that over-coding business rules into modules and services locks you into business processes that are slow or impossible to update. There’s no mention that most times you want to build services that offer platform capabilities, not focus on what 'domain' they fall into. Never mind that 'domain' is basically undefined and can mean many different concepts and different types of concepts."

Go for Simple

SwiftUI thrives on simplicity. Not endless layers of abstractions. Instead of wasting time on "domains", "repositories", and "use cases". Let's focus on delivering features quickly with less code.