If you’re building apps alone, architecture can feel like a trap.
On one side: the wild west. Everything talks to everything, your UI calls the API directly, business rules end up copy/pasted, and a “quick fix” turns into a permanent mess.
On the other side: over-engineering. You get sold a stack of patterns — clean architecture, CQRS, event sourcing, hexagonal adapters, 12 modules, 40 folders, and a dependency injection graph that needs its own diagram. You haven’t shipped yet, but you already have “infrastructure.”
Solo development doesn’t need a philosophy war. You need a repeatable structure that:
- keeps you shipping
- makes debugging obvious
- keeps changes local
- doesn’t require meetings (because you are the meeting)
This guide gives you a minimal architecture pattern that works across Flutter, React Native, iOS (SwiftUI), and Android (Compose). It’s intentionally boring. It’s not “enterprise.” It is a small set of rules that stays useful from your first screen to your first paying customer — and still won’t collapse when you add features.
Related guides on sminrana.com:
Why solo devs over-engineer (and why it hurts)
Over-engineering isn’t about intelligence. It’s usually about anxiety.
When you’re solo, every future risk feels personal:
- “What if I need to swap API providers later?”
- “What if this becomes huge?”
- “What if I need offline mode?”
- “What if I hire contractors?”
So you build a fortress. You add abstractions “just in case.” You separate everything into layers before you even know what the app does. Your code becomes a museum of hypothetical problems.
The cost shows up fast:
- Slower iteration: each change crosses five files and three folders.
- Lower confidence: you can’t tell where the truth lives.
- More bugs: abstractions hide flows that should be explicit.
- Higher cognitive load: you spend energy navigating structure instead of building.
The goal isn’t “no architecture.” The goal is the smallest architecture that gives you leverage.
What “minimal architecture” actually means
Minimal doesn’t mean tiny; it means necessary.
A minimal architecture is one where:
- every layer exists to solve a real problem you’ve already hit
- boundaries are clear enough that you can replace parts without collateral damage
- most code is easy to delete later
When you find yourself asking, “Is this clean?”, ask a different question:
“If I rewrite this feature in two weeks, will it be painful?”
Minimal architecture optimizes for inevitable rewrites.
The pattern: a strict 3-layer architecture
This is the entire model:
- UI layer: screens and state holders. Renders state, triggers actions.
- Domain layer: use-cases and domain models. Pure business logic, no framework types.
- Data layer: repositories and data sources. Talks to network, database, storage.
The magic isn’t the layers — you’ve heard that before. The magic is the strict dependency direction:
- UI can depend on Domain.
- Domain can depend on nothing (or only simple shared utilities).
- Data can depend on Domain models/interfaces.
- Domain never imports Data.
- UI never imports raw network DTOs.
That’s it.
If you keep those rules, you get the benefits people chase with complex architectures:
- You can test business logic quickly.
- You can stub data for UI development.
- You can swap storage and API details.
- You can refactor without fear.
And you can do it without building a cathedral.
When you should (and shouldn’t) use this
If you’re a solo developer building:
- a mobile app with login + CRUD + subscriptions
- a tool for a niche audience
- a micro-SaaS companion app
- an MVP you want to ship in days/weeks
…this is a great default.
When not to use it as-is:
- You’re building a high-frequency trading platform. (Not your blog audience, likely.)
- You already have a large team with a strict architecture and shared tooling.
- Your app is extremely tiny (one screen, no API). For that, keep it simpler and skip the layers.
Minimal architecture is not a religion. It’s a starting point.
The core principles (print this and stick it above your desk)
1) Patterns follow problems
Don’t add a layer to prove you’re serious. Add a layer when you feel pain:
- repeated code
- impossible-to-test logic
- coupling that makes changes risky
If the pain isn’t there yet, write the boring code and move on.
2) Make boundaries obvious
Your future self should be able to answer:
- “Where does this rule live?”
- “What calls what?”
- “Where does caching happen?”
If you can’t answer in 10 seconds, the abstraction isn’t helping.
3) Keep state flows explicit
Most mobile bugs are “state is wrong.” Your architecture should make it hard to hide state mutations.
A good rule of thumb:
- UI owns UI state.
- Domain owns business decisions.
- Data owns side effects.
4) Fewer dependencies, fewer problems
Every third-party dependency is an additional system you must mentally simulate.
Default to:
- platform standard libraries
- small, well-known libraries
- first-party tools
If you pull in a library, write down what you’re buying and what you’re paying.
Layer 1: UI (screens + state holders)
The UI layer has two jobs:
- render data
- dispatch user intent
It should not:
- parse JSON
- decide pricing rules
- format error categories
- implement caching logic
The UI can contain:
- views/screens/components
- view models / controllers / state notifiers
- UI-specific mappers (like mapping a domain model to displayed strings)
A simple UI rule that prevents chaos
UI can call only use-cases (or a small facade) — not repositories, and never raw API clients.
That rule does two things:
- It forces business logic out of the UI.
- It creates a stable “API” for the UI, even while data changes.
What UI state should look like
Avoid “a dozen booleans” state.
Prefer a single object (sealed class / union type / enum + payload) that represents the screen:
- Loading
- Empty
- Content
- Error
Example TypeScript (React Native):
type TodosState =
| { kind: "loading" }
| { kind: "empty" }
| { kind: "content"; items: Todo[] }
| { kind: "error"; message: string; canRetry: boolean };
This makes it hard to end up in impossible states like loading=true and error=trueand items!=[].
Layer 2: Domain (use-cases + models)
The domain layer is the brain of your app.
It contains:
- domain models: plain data structures that represent concepts the user cares about
- use-cases: single-purpose operations like
CreateTodo,FetchProfile,SubmitOrder
A use-case is not a “service with 20 methods.” It is a function/class that does one job.
What belongs in the domain layer
Business rules, not UI rules.
Examples:
- “A trial can be started once per user.”
- “A booking must be within the next 180 days.”
- “VAT applies only if country is X.”
- “If the network fails, fallback to cached data if it’s fresh enough.” (Yes, staleness rules are business rules.)
Examples of what does not belong in the domain layer:
- “Show a toast”
- “Use Material 3 colors”
- “Debounce button taps by 500ms” (UI concern)
Domain types must be framework-free
This rule matters more than people realize.
Your domain models shouldn’t import:
- Flutter
BuildContext - React
useState - SwiftUI
View - Kotlin
Context - network client classes
Domain should be boring and portable.
That’s not because you’ll reuse it across apps (sometimes you will, often you won’t). It’s because when types are “clean,” tests and refactors become easy.
Example: use-case + repository contract
Example contract (TypeScript):
export interface TodoRepo {
list(): Promise<Todo[]>;
add(title: string): Promise<Todo>;
toggle(id: string): Promise<Todo>;
}
Use-case:
export class AddTodo {
constructor(private repo: TodoRepo) {}
async run(title: string): Promise<Todo> {
const trimmed = title.trim();
if (trimmed.length < 3) {
throw new Error("Title too short");
}
return this.repo.add(trimmed);
}
}
Notice what’s missing:
- no HTTP
- no storage
- no UI
Just the rule.
Work in “nouns” and “verbs”
A helpful mental model:
- nouns:
User,Todo,Order,Plan - verbs:
CreateOrder,ApplyCoupon,FetchTodos
If you name things this way, your architecture stays grounded in product behavior, not technology.
Layer 3: Data (repositories + sources)
Data is where side effects live.
It contains:
- repository implementations
- remote data sources (API clients)
- local data sources (database, key-value storage)
- DTOs and mapping
This is the only layer that should know how data is stored or fetched.
Repository interfaces vs implementations
If you’re building solo, you don’t need 20 interfaces for the sake of it. You need interfaces at boundaries where swap-ability is valuable:
- the boundary between domain and data (so use-cases can be tested without network)
- the boundary between repository and its sources (so caching/offline can change without breaking use-cases)
Keep the interface small. If it grows, your model is unclear.
DTO mapping: keep the mess contained
Network JSON is often:
- inconsistent
- abbreviated
- not quite correct
- likely to change
That mess should not leak into domain.
Instead, map DTO → domain model at the data boundary.
Example:
type TodoDTO = { id: string; t: string; d: 0 | 1 };
function mapTodoDto(dto: TodoDTO): Todo {
return { id: dto.id, title: dto.t, done: dto.d === 1 };
}
This is boring. That’s why it works.
A practical caching approach (without building a spaceship)
If you need caching, don’t invent “cache managers.” Put caching in the repository.
A simple approach:
- repository tries remote
- on success: store locally and return
- on failure: try local if available
You can later add “cache freshness” rules without changing the UI or domain.
The dependency rules (the part you must not break)
Write these rules in your CONTRIBUTING.md or keep them as a team agreement (even if the team is you):
- UI imports Domain.
- Domain imports nothing (except shared primitives).
- Data imports Domain.
- Domain exposes interfaces; Data implements them.
- DTOs stay in Data.
- UI never sees DTOs.
If you violate these rules, you’re not “being pragmatic.” You’re creating invisible coupling.
Directory structure that stays sane
Folder structure isn’t architecture, but it strongly influences how you think.
Here’s a structure that works across frameworks:
Flutter
lib/
ui/
screens/
widgets/
state/
domain/
models/
usecases/
repos/
data/
repos/
sources/
dto/
mappers/
React Native
src/
ui/
screens/
components/
state/
domain/
models/
usecases/
repos/
data/
repos/
api/
storage/
dto/
mappers/
iOS (Swift)
App/
UI/
Screens/
Components/
State/
Domain/
Models/
UseCases/
Repos/
Data/
Repos/
Remote/
Local/
DTO/
Mappers/
Android (Kotlin)
app/src/main/java/.../
ui/
screen/
state/
domain/
model/
usecase/
repo/
data/
repo/
remote/
local/
dto/
mapper/
This is intentionally repetitive. Repetition is clarity.
A complete end-to-end example (the “feature slice” method)
When solo devs get stuck, it’s often because they’re building architecture “horizontally”:
- build all models
- build all repositories
- build all screens
That approach creates a giant gap between code and value.
Build “feature slices” instead: one end-to-end vertical path at a time.
Let’s do an example: “Add a todo item.”
Step 1: Domain model
Keep it simple.
export type Todo = {
id: string;
title: string;
done: boolean;
};
Step 2: Repository interface in domain
export interface TodoRepo {
add(title: string): Promise<Todo>;
}
Step 3: Use-case in domain
export class AddTodo {
constructor(private repo: TodoRepo) {}
async run(input: { title: string }): Promise<Todo> {
const title = input.title.trim();
if (title.length === 0) throw new Error("Title required");
if (title.length > 140) throw new Error("Title too long");
return this.repo.add(title);
}
}
Step 4: Data implementation
Create an API DTO, a mapper, and the repository implementation.
type TodoDTO = { id: string; title: string; done: boolean };
function dtoToDomain(dto: TodoDTO): Todo {
return { id: dto.id, title: dto.title, done: dto.done };
}
export class HttpTodoRepo implements TodoRepo {
constructor(
private client: { post: (path: string, body: any) => Promise<any> },
) {}
async add(title: string): Promise<Todo> {
const res = await this.client.post("/todos", { title });
return dtoToDomain(res as TodoDTO);
}
}
Even if you later change networking libraries, everything above stays stable.
Step 5: UI wiring
UI calls the use-case, renders output.
The UI layer doesn’t know about DTOs or HTTP; it knows only AddTodo.
This gives you a small, testable integration seam: you can pass a fake repo in tests, or a stub repo during early prototyping.
Error handling without drama
Most architecture posts talk about error handling as if you need a framework.
What you really need is consistency.
A minimal error strategy
- Data layer converts low-level failures into typed failures (network down, unauthorized, timeout).
- Domain decides what to do (retry? fallback? stop?).
- UI decides how to show it (message + action).
If you want a single simple type, use “Result” style return values.
Kotlin sealed results:
sealed class Result<out T> {
data class Ok<T>(val value: T): Result<T>()
data class Err(val error: Throwable): Result<Nothing>()
}
Swift:
enum Result<T> {
case ok(T)
case err(Error)
}
Dart and TypeScript have similar patterns (or you can use exceptions carefully). Pick one and use it consistently.
A rule that saves time
Never show raw technical errors to users.
Instead, map technical issues into a small set of user-friendly categories:
- “You’re offline.”
- “Session expired, please log in again.”
- “Something went wrong. Try again.”
Put the mapping at the UI edge, where copy is managed.
Observability lite: logs that actually help
Solo devs often skip observability until it’s painful. Then they add too much.
Minimal observability looks like this:
- Log at boundaries, not everywhere.
- Include one correlation ID per user flow.
- Track 3–5 events that represent product progress.
Examples of boundary logs:
- UI: user tapped “Pay”
- Domain: order validated, coupon applied
- Data: POST /checkout started/finished, status code
This is enough to debug 80% of issues without a “logging architecture.”
Performance budgets that don’t waste your life
Over-engineering often hides performance work behind abstractions.
Minimal performance practice:
- define a startup target (e.g., “home screen in under 2 seconds on mid-range phone”)
- define a screen render budget (“no frame drops in main scrolling screens”)
- watch bundle size (especially RN)
Then apply boring optimizations:
- lazy-load heavy dependencies
- defer non-critical initialization
- compress images
- cache simple responses
You don’t need a “performance layer.” You need a few measurable constraints.
Testing that matters for solo devs
You don’t need 90% coverage. You need confidence.
The testing priority list
- Domain unit tests: fastest, most valuable.
- Repository integration tests: verify mapping + caching rules.
- UI smoke tests: only for critical flows.
What to test in the domain
Test:
- validation rules
- decision rules (what happens with edge cases)
- transformations (prices, dates, sorting)
Don’t test:
- widgets rendering every pixel
- HTTP client correctness (that’s the library’s job)
The solo-dev litmus test
If a test takes 30 seconds to run, you’ll stop running it.
Build a test set that runs in seconds, and you’ll actually use it.
Framework blueprints (practical mappings)
This pattern isn’t tied to a specific state manager or DI library. Here’s how it translates cleanly.
Flutter
- UI: Widgets + state holders (
ChangeNotifier, RiverpodNotifier, Bloc, etc.) - Domain: pure Dart models + use-case classes/functions
- Data: repositories wrapping Dio/http + Hive/Isar/SharedPreferences
A minimal Flutter wiring approach:
- Create repository implementations in
data/ - Pass them into use-cases
- Inject use-cases into state holders
If you use Riverpod, your providers become the wiring layer. Keep them thin.
React Native
- UI: screens + hooks
- Domain: use-case classes/functions (plain TS)
- Data: API client + storage
Keep UI state local unless it truly spans screens. State libraries are easy to overuse in RN.
iOS (SwiftUI)
- UI:
View+ObservableObject/@StateObject - Domain: pure Swift structs + use-case types
- Data: URLSession + persistence (CoreData/SQLite/File)
A practical SwiftUI tip: make your ViewModel the UI boundary and keep domain logic out.
Android (Compose)
- UI: Composables +
ViewModel - Domain: use-cases + domain models
- Data: repository + Retrofit/Room
Your ViewModel can call use-cases and expose state as StateFlow.
Common anti-patterns (and what to do instead)
Anti-pattern 1: “God service”
Symptom:
ApiServicehas 40 methodsAppRepositorybecomes a dumping ground
Fix:
- split by feature or domain concept (
AuthRepo,TodoRepo,BillingRepo) - keep interfaces small and cohesive
Anti-pattern 2: Deep inheritance trees
Inheritance makes changes hard because behavior is spread across classes.
Fix:
- use composition
- pass small collaborators into classes
Anti-pattern 3: Premature modularization
Breaking your app into many modules early creates friction: build times, wiring, and navigation across modules.
Fix:
- start with folders
- extract modules only when boundaries are repeatedly painful (or compile times demand it)
Anti-pattern 4: “Architecture by library”
If your architecture requires a specific library to exist, it’s fragile.
Fix:
- define your own boundaries (interfaces + use-case API)
- treat libraries as replaceable implementation details
A simple migration path when the app grows
This pattern scales surprisingly far. Still, you’ll eventually hit a few thresholds.
When to split into modules
Consider extracting modules when:
- build times get noticeably slow
- features are mostly independent
- you’ll onboard another dev
Before that, folders are fine.
When to add more architectural structure
Add complexity only when you can name the pain.
Examples:
- offline-first requirements → add a local data source and sync rules
- multiple environments/tenants → introduce configuration boundaries
- heavy business logic → split domain into feature domains (
OrdersDomain,BillingDomain)
What to keep the same
Even if you evolve toward “clean architecture,” keep the small soul of this pattern:
- explicit boundaries
- stable domain model
- data details don’t leak
Your minimal architecture checklist
If you want a practical “am I doing this right?” list, use this:
- UI calls use-cases, not repositories.
- Domain types contain no framework imports.
- Data layer is the only place with DTOs.
- Repositories hide caching/offline logic from UI.
- Every screen has an explicit state model (loading/empty/content/error).
- You can test a use-case without network.
- Folder structure makes it obvious where things go.
If you can check these boxes, you’re not over-engineering.
FAQ
“Isn’t this basically Clean Architecture?”
It overlaps, but it’s intentionally smaller.
Clean Architecture can turn into a lot of ceremony: entities, interactors, presenters, gateways, multiple models per layer.
This pattern keeps the parts that create leverage for a solo dev:
- use-cases for business logic
- repos as boundaries
- DTO mapping
…and skips the rest until you need it.
“What about dependency injection?”
Use DI only as much as you need.
- In small apps: manual wiring is fine.
- In medium apps: a lightweight DI mechanism (or provider system) is fine.
The key is that DI is a wiring tool, not the architecture.
“What if I’m building both iOS and Android?”
This pattern helps more when you’re multi-platform, because it gives you a consistent mental model.
Even if you don’t share code, sharing the shape of the architecture reduces context switching.
“What if my domain logic is tiny?”
Then keep it tiny.
A domain layer can start as:
domain/modelsdomain/usecases
Five files total is still a domain layer.
Next actions (do this in the next 60 minutes)
- Pick one feature in your app (login, list, checkout).
- Draw three boxes: UI → Domain → Data.
- Move one business rule from UI into a use-case.
- Add one repository interface and one fake implementation.
- Ship a small improvement end-to-end.
Minimal architecture isn’t a refactor project. It’s a way of working.



