Architecture With Nico

Practical software architecture and design for iOS engineers.

AI Coding Still Needs Architecture

AI coding tools are very good at writing code that looks reasonable.

That is useful.

It is also the problem.

When a tool can generate a screen, a ViewModel, a repository, a network client, tests, and a few abstractions in a couple of minutes, the bottleneck is no longer typing. The bottleneck is direction.

Where should the dependency be created?

Who owns navigation?

Is this use case a useful boundary, or did we just ask the model for "clean architecture" and get five files by default?

Should the ViewModel call the repository, call a use case, receive use case outputs, or only map state?

These were already architecture questions before AI. AI just makes the consequences arrive faster.

#Code Is Cheaper Now

Most architecture conversations assume that code is expensive to produce.

That is still true in one sense. Code is expensive to understand, test, maintain, debug, migrate, and coordinate across people.

But code is becoming cheaper to generate.

That changes the shape of the problem.

If writing code is cheap, a team can create architectural drift very quickly. One prompt can put networking in a ViewModel. Another can hide dependencies in SwiftUI's environment. Another can add protocols for every concrete type because "testability". Another can make the factory decide navigation because the example looked convenient.

None of those changes need to be malicious. They can all be locally plausible.

That is what makes them dangerous.

AI does not usually break your architecture by writing obviously absurd code. It breaks it by choosing a reasonable-looking default that does not match your project.

#Prompts Are Not Enough

You can tell the tool:

Keep this clean. Use MVVM. Follow SOLID. Make it testable.

The model will try.

But those words are too broad.

Different teams mean different things by MVVM. Some teams want ViewModels to trigger use cases directly. Some want ViewModels to receive outputs and only map state. Some teams put navigation in ViewModels. Some prefer flows or coordinators. Some treat protocols as boundaries. Some create protocols mainly for mocks.

"Make it clean" does not answer those questions.

It only asks the model to guess your taste.

And the model is very good at guessing confidently.

#Architecture Is a Project Interface

This is why I like giving AI coding tools an ARCHITECTURE.md file.

Not because a Markdown file is magic.

Because it turns repeated architectural decisions into a project interface.

The file says:

  • Views render and report user intent.
  • ViewModels expose renderable state, map meaningful results into state, and emit small UI events.
  • Use cases perform application operations.
  • Repositories hide data policy and data-source details.
  • Factories build feature graphs.
  • Composition roots choose concrete implementations.
  • Flows or parent features own navigation decisions.

That is much more useful than "use clean architecture".

It gives the agent names, boundaries, and defaults.

It also gives the reviewer something concrete to point at. Instead of saying "this feels wrong", you can say "this puts navigation in a leaf ViewModel, and our architecture guide says flows own that decision."

That makes code review less personal and more precise.

#A Small Example

Imagine asking an AI tool to build a login screen.

Without stronger guidance, this is a very plausible shape:

@MainActor
final class LoginViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published var isLoading = false
    @Published var path = NavigationPath()

    private let apiClient = URLSessionAuthClient()
    private let analytics = Analytics.shared

    func login() async {
        isLoading = true
        analytics.track("login_started")

        do {
            _ = try await apiClient.login(
                email: email,
                password: password
            )
            analytics.track("login_succeeded")
            path.append(AppRoute.home)
        } catch {
            analytics.track("login_failed")
            isLoading = false
        }
    }
}

This is not ridiculous code.

It is testable with enough mocking. It may even work.

But the ViewModel is now doing almost everything:

  • holding form state
  • creating infrastructure
  • performing the application operation
  • tracking analytics policy
  • interpreting errors
  • owning navigation
  • deciding the next app route

The feature has a shape, but the shape is accidental.

#The Guide Gives the Model a Better Default

With an architecture file, the request can produce a different kind of result.

The ViewModel can stay focused on state:

enum LoginScreenEvent {
    case loginCompleted
}

enum LoginFailure {
    case invalidCredentials
}

protocol LoginUseCaseOutput: AnyObject {
    func loginDidStart()
    func loginDidSucceed()
    func loginDidFail(_ failure: LoginFailure)
}

@MainActor
final class LoginViewModel: ObservableObject, LoginUseCaseOutput {
    @Published private(set) var isLoading = false
    @Published private(set) var errorMessage: String?

    var onEvent: (LoginScreenEvent) -> Void = { _ in }

    func loginDidStart() {
        isLoading = true
        errorMessage = nil
    }

    func loginDidSucceed() {
        isLoading = false
        onEvent(.loginCompleted)
    }

    func loginDidFail(_ failure: LoginFailure) {
        isLoading = false
        errorMessage = "Could not log in."
    }
}

The use case can own the application operation:

final class LoginUseCase {
    private let authenticator: Authenticating
    private let sessionStore: SessionStoring
    private weak var output: LoginUseCaseOutput?

    init(
        authenticator: Authenticating,
        sessionStore: SessionStoring,
        output: LoginUseCaseOutput
    ) {
        self.authenticator = authenticator
        self.sessionStore = sessionStore
        self.output = output
    }

    func execute(email: String, password: String) async {
        await MainActor.run {
            output?.loginDidStart()
        }

        do {
            let session = try await authenticator.login(
                email: email,
                password: password
            )
            try await sessionStore.save(session)

            await MainActor.run {
                output?.loginDidSucceed()
            }
        } catch {
            await MainActor.run {
                output?.loginDidFail(.invalidCredentials)
            }
        }
    }
}

The flow can own the navigation meaning:

@MainActor
final class LoginFlow: ObservableObject {
    enum Route: Hashable {
        case home
    }

    @Published var path = NavigationPath()

    func handle(_ event: LoginScreenEvent) {
        switch event {
        case .loginCompleted:
            path.append(Route.home)
        }
    }
}

And the factory can wire the pieces without deciding what login completion means:

struct LoginFeatureFactory {
    let authenticator: Authenticating
    let sessionStore: SessionStoring

    func makeLoginScreen(
        onEvent: @escaping (LoginScreenEvent) -> Void
    ) -> LoginScreen {
        let viewModel = LoginViewModel()
        viewModel.onEvent = onEvent

        let useCase = LoginUseCase(
            authenticator: authenticator,
            sessionStore: sessionStore,
            output: viewModel
        )

        return LoginScreen(
            viewModel: viewModel,
            onSubmit: useCase.execute
        )
    }
}

This is not about having more files.

It is about giving decisions a home.

If the app is tiny, this may be more structure than you need. That is fine. But in a real app with multiple flows, dependencies, tests, and teams, the structure gives both humans and AI tools a path to follow.

#The File Should Be Specific

An architecture file should not be a motivational poster.

This is too vague:

Use clean architecture.
Follow SOLID.
Write testable code.
Avoid tight coupling.

Those are nice preferences, but they do not decide anything.

Useful project guidance sounds more like this:

Use @StateObject when the view creates the observable object and owns its lifetime.
Use @ObservedObject when the observable object is created and owned elsewhere.
Do not wrap an injected ViewModel in @StateObject just because it is a ViewModel.

Or:

Factories build objects and connect dependencies.
Factories do not interpret screen events or decide navigation meaning.
Flows or parent features own navigation decisions.

That kind of guidance prevents a very specific class of mistakes.

The more specific the file is, the less the model has to invent.

#The File Should Not Freeze the Project

There is a danger here.

If an architecture file becomes a rigid rulebook, people will stop improving it.

That would miss the point.

The file should be a living document. When code review reveals the same problem three times, add a rule. When a rule starts producing awkward code, rewrite it. When the team learns a better pattern, update the guide.

The file is not there to make architecture static.

It is there to make architectural intent visible.

For example, I recently had to clarify one of my own rules around SwiftUI object ownership. It is easy to accidentally write examples that make it sound like "ViewModels should be @StateObject."

That is not the rule.

The real rule is ownership:

  • if the view creates and owns the observable object, use @StateObject
  • if the object is injected and owned elsewhere, use @ObservedObject

That distinction belongs in the guide because AI tools will absolutely copy the wrong pattern if the examples are ambiguous.

So will humans, honestly.

#Architecture Still Belongs to People

AI can help write code.

It can suggest designs.

It can follow examples.

It can even help notice inconsistencies.

But it does not know what tradeoffs your project wants unless you tell it.

Architecture is still the act of deciding what should be easy to change, what should be explicit, and where important decisions belong.

AI does not remove that responsibility.

It makes it more visible.

#The Guide I Use

I keep a public version of my reusable AI coding guides here:

github.com/frugoman/ai-coding-guides

The iOS guide is here:

iOS ARCHITECTURE.md

And if you want to copy it into a project:

curl -o ARCHITECTURE.md https://raw.githubusercontent.com/frugoman/ai-coding-guides/main/ios/ARCHITECTURE.md

You should not treat it as universal truth.

You should treat it as a starting point.

Change the parts that do not match your app. Delete the parts that add ceremony without value. Add the decisions your team keeps repeating in reviews.

The important thing is not that your file matches mine.

The important thing is that your project has somewhere for those decisions to live.

When AI can write code quickly, architecture is how we keep that speed pointed in the right direction.