Clean Architecture, SOLID and the patterns that separate lasting code from technical debt. Every pattern has a context and a cost.
"The best architecture is the one that works, not the one that is fashionable. Pragmatism before purism."
Cesar Zanis
01
SOLID: the principles that matter most day to day
All five principles matter. S, O and D are the ones you are likely to use every week. L and I are important too, but are violated less frequently.
S
Single Responsibility
One class, one reason to change.
A class that sends email, validates data AND writes to the database is hard to test and harder to change. Separate responsibilities by their reason to change, not by their size.
Problematic
// ❌ Does everything: hard to test, hard to change
class OrderService {
validate(order) { /* validation */ }
save(order) { /* database */ }
sendEmail(order) { /* SMTP */ }
generatePDF(order) { /* PDF */ }
}
Correct
// ✅ Each class has one reason to exist
class OrderValidator { validate(order): Result<void> }
class OrderRepository { save(order): Promise<void> }
class OrderNotifier { notify(order): Promise<void> }
class OrderPdfGenerator { generate(order): Buffer }
O
Open/Closed
Open for extension, closed for modification.
You should not have to edit an existing class whenever a new payment method appears. Use interfaces and polymorphism to extend behavior without changing what already works.
Problematic
// ❌ Each new payment method requires editing this class
class PaymentProcessor {
process(method: string, amount: number) {
if (method === 'pix') { /* ... */ }
else if (method === 'credit') { /* ... */ }
else if (method === 'boleto') { /* ... */ }
// One more if per method: risk of breaking existing behavior
}
}
Correct
// ✅ New method = new class, without touching the core
interface PaymentStrategy {
process(amount: number): Promise<PaymentResult>
}
class PixPayment implements PaymentStrategy { ... }
class CreditCardPayment implements PaymentStrategy { ... }
class BoletoPayment implements PaymentStrategy { ... }
class PaymentProcessor {
constructor(private strategy: PaymentStrategy) {}
process(amount: number) { return this.strategy.process(amount) }
}
D
Dependency Inversion
Depend on abstractions, not implementations.
Your order service should depend on an IPaymentGateway interface instead of importing Stripe directly. That lets you replace Stripe with PagSeguro without changing business logic, and test without making real requests.
Problematic
// ❌ Coupled to the implementation: tests call Stripe directly
class OrderService {
private stripe = new StripeClient(process.env.STRIPE_KEY)
async charge(order: Order) {
return this.stripe.charges.create({ amount: order.total })
}
}
Correct
// ✅ Depends on an abstraction: testable and replaceable
interface IPaymentGateway {
charge(amount: number, currency: string): Promise<ChargeResult>
}
class OrderService {
constructor(private payment: IPaymentGateway) {}
async charge(order: Order) {
return this.payment.charge(order.total, order.currency)
}
}
// In production: new OrderService(new StripeGateway())
// Em teste: new OrderService(new MockPaymentGateway())
02
Patterns that solve real problems
Result Pattern: errors without exceptions
A pattern that makes complex APIs easier to read.
Exceptions should not control expected business outcomes such as an existing email address or insufficient funds. Reserve them for unexpected failures, such as an unavailable database or a full disk. Use Result<T> for business outcomes: return success or failure explicitly and require the caller to handle both.
TypeScript
// The type
type Result<T, E = string> =
| { ok: true; value: T }
| { ok: false; error: E }
// The service: no throw for business outcomes
class UserService {
async register(email: string, password: string): Promise<Result<User>> {
const exists = await this.repo.findByEmail(email)
if (exists) {
return { ok: false, error: 'EMAIL_ALREADY_REGISTERED' }
}
const user = await this.repo.create({ email, password: await hash(password) })
return { ok: true, value: user }
}
}
// The controller: required to handle the error
async register(req, res) {
const result = await this.userService.register(req.body.email, req.body.password)
if (!result.ok) {
return res.status(409).json({ error: result.error })
}
return res.status(201).json(result.value)
}
Repository Pattern: the database as a detail
Your data layer is not your domain. Treat it as an implementation detail.
Business services should not need to know whether data comes from PostgreSQL, Redis or an external API. A Repository abstracts that access. You can change the database without changing business logic, and test that logic without a real database.
TypeScript
// The interface: in the domain, with no database dependency
interface IOrderRepository {
findById(id: string): Promise<Order | null>
findByCustomer(customerId: string): Promise<Order[]>
save(order: Order): Promise<void>
delete(id: string): Promise<void>
}
// The implementation: an infrastructure detail
class PostgresOrderRepository implements IOrderRepository {
async findById(id: string): Promise<Order | null> {
const row = await this.db.query(
'SELECT * FROM orders WHERE public_id = $1 AND deleted_at IS NULL',
[id]
)
return row ? OrderMapper.toDomain(row) : null
}
// ...
}
// The service: database agnostic
class OrderService {
constructor(private orders: IOrderRepository) {}
async cancel(orderId: string): Promise<Result<void>> {
const order = await this.orders.findById(orderId)
if (!order) return { ok: false, error: 'ORDER_NOT_FOUND' }
order.cancel() // domain logic
await this.orders.save(order)
return { ok: true, value: undefined }
}
}
03
OWASP Top 10: common issues
Security belongs in the foundations. The five issues below deserve particular attention in backend reviews.
A01
Broken Access Control
Check authorization on the backend for EVERY request. Never rely on the frontend for access control. Test an unauthorized user trying to access someone else's resource.
A02
Cryptographic Failures
Use bcrypt or Argon2 for passwords, never MD5 or unsalted SHA. Use TLS for every connection. Never log sensitive data such as tokens, passwords or national identifiers.
A03
SQL Injection
Always use parameterized queries. Never concatenate user input into SQL. ORMs help, but review the SQL they generate.
A07
Auth Failures
Use short-lived JWTs (15-60 minutes) with rotating refresh tokens. Rate-limit /login. Password reset responses should not reveal whether an email address exists.
A09
Inadequate logging
Log enough to reconstruct an incident. Record the event, not the sensitive data: 'User X accessed resource Y', without recording the resource's contents.
Rule of thumb: before applying a pattern, ask: 'What concrete problem does this solve today?' If the answer is 'None, but it might be useful later', you are adding complexity in advance. Wait for the problem to appear.