Clean Architecture killed our velocity - Here's what we did instead
Last year, our team inherited a banking app with “perfect” Clean Architecture: 8 layers, 23 modules, interfaces for everything. Adding a simple button took 5 files across 3 modules. Our sprint velocity was dead.
Here’s what actually matters when 2 million users trust you with their money.
The Over-Engineering Trap We Fell Into
Our original architecture (that everyone praised in tech talks):
app → presentation → domain → data →
├── local → database
├── remote → network
└── cache → redis
The reality:
- 15-minute compile times
- New devs took 3 weeks to ship first feature
- A simple “Show account balance” touched 12 files
- 40% of our interfaces had single implementations
What Banking Apps Actually Need
After analyzing 6 months of crash data and user complaints:
- Reliability - 0.01% crash rate (we had 2.3%)
- Fast cold start - Under 1.5s (we took 3.8s)
- Offline support - Users check balance without internet
- Security - Obviously
- Quick feature delivery - Compete with fintechs
Clean Architecture gave us none of these.
Our Pragmatic 3-Layer Approach
We collapsed to just what we needed:
// 1. UI Layer - Compose + ViewModel
@Composable
fun BalanceScreen(
viewModel: BalanceViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsState()
when (state) {
is Success -> BalanceCard(state.balance)
is Loading -> ShimmerCard()
is Error -> ErrorCard(state.message)
}
}
// 2. ViewModel - Business logic lives here
@HiltViewModel
class BalanceViewModel @Inject constructor(
private val repo: AccountRepository
) : ViewModel() {
val state = repo.getBalance()
.map { balance ->
// Business rule: Hide balance if < $0
if (balance.amount < 0) {
Success(balance.copy(isHidden = true))
} else {
Success(balance)
}
}
.catch { emit(Error(it.toUserMessage())) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
Loading
)
}
// 3. Repository - Just data, no business logic
@Singleton
class AccountRepository @Inject constructor(
private val api: BankingApi,
private val db: AccountDao
) {
fun getBalance(): Flow<Balance> = flow {
// Always emit cached first (instant UI)
db.getBalance()?.let { emit(it) }
try {
val fresh = api.getBalance()
db.saveBalance(fresh)
emit(fresh)
} catch (e: Exception) {
// If no cache, rethrow
if (db.getBalance() == null) throw e
}
}
}
That’s it. Three files instead of twelve.
Results After 6 Months
Development Speed:
- Feature delivery: 2.1x faster
- New dev onboarding: 5 days → 2 days
- Build time: 15min → 3min
App Performance:
- Crash rate: 2.3% → 0.08%
- Cold start: 3.8s → 1.1s
- APK size: 62MB → 41MB
Business Impact:
- App store rating: 3.2 → 4.6
- Monthly active users: +34%
- Customer complaints: -67%
When You Actually Need More Layers
We’re not saying Clean Architecture is always wrong. Add layers when you have:
- Multiple teams (>15 devs) working on same codebase
- Multiple apps sharing business logic
- Complex domain (trading apps, not CRUD)
- Regulatory requirements for separation
We had none of these. We had 6 developers building a banking app.
The Interfaces We Actually Kept
We only create interfaces for:
- External dependencies we might swap:
interface BiometricAuth {
suspend fun authenticate(): Boolean
}
// Prod: Samsung/Google implementation
// Debug: Auto-success for testing
// Tests: Controllable mock
- Platform-specific implementations:
interface SecureStorage {
fun savePin(pin: String)
fun getPin(): String?
}
// Android: EncryptedSharedPreferences
// iOS: Keychain (if we go KMM)
Not for every damn repository and use case.
Trade-offs We Accepted
We lost:
- “Clean” layer separation
- Easy unit testing of everything
- Architecture diagram bragging rights
We gained:
- Shipping features users want
- Developers who don’t hate the codebase
- A 4.6-star app that makes money
The One Rule That Matters
Can a mid-level developer add a feature in under a day?
If not, your architecture is wrong for your team size.
What About Testing?
We test what breaks and costs money:
@Test
fun `negative balance triggers overdraft fee`() {
val account = Account(balance = -50.0)
val fee = calculateOverdraftFee(account)
assertThat(fee).isEqualTo(35.0)
}
@Test
fun `transfer fails if daily limit exceeded`() {
val result = transfer(
amount = 10_000,
dailyLimit = 5_000
)
assertThat(result).isInstanceOf(DailyLimitExceeded::class)
}
Not:
@Test
fun `GetUserUseCase calls repository`() {
// Who cares?
}
Migration Strategy (If You’re Stuck in Over-Architecture)
- Stop adding layers - No new interfaces “for future flexibility”
- Collapse on feature work - When touching a feature, simplify it
- Measure velocity - Track time from ticket to production
- Delete unused abstractions - If it has one implementation, inline it
We migrated gradually over 4 months while shipping features.
One Year Later
Our “messy” 3-layer architecture processes $50M daily in transactions with 99.98% uptime.
The team that insisted on Clean Architecture? They’re still debating whether TransferMoneyUseCase
should return a Result<TransferEntity>
or Flow<TransferState>
.
Ship features. Make money. Stop masturbating over architecture.
P.S. - Yes, I’ve read Clean Code. Yes, I understand SOLID. No, your banking app doesn’t need 8 layers. Focus on your users, not your diagram.