Navegando no Labirinto da Dívida Técnica com Três Princípios Fundamentais
Antes de começarmos, gostaria de deixar claro que toda opinião dada neste texto foi baseada em minha experiência durantes alguns anos no mundo do desenvolvimento. De fato existem estratégias melhores e até mais eficientes, porém essas que irão ler são as que melhor me adaptei no meu dia a dia.
Muitos desenvolvedores já ouviram o conselho: "Evite dívida técnica", "Refatore sempre", etc. Eu costumava compartilhar dessa visão, resultando em gastar mais tempo do que o necessário na entrega e gerando código desnecessariamente complexo.
Usando uma analogia de Ward Cunningham, a dívida técnica é semelhante a um empréstimo financeiro. Se você não entende a mecânica financeira, como spreads e juros, é provável que caia na armadilha de um mau empréstimo, prejudicando sua capacidade de pagá-lo confortavelmente.
O mesmo princípio se aplica ao código. Ocasionalmente, precisamos de soluções de atalho para validar recursos e determinar o que realmente precisa ser feito.
Como Lido Com a Dívida Técnica
Minha base de desenvolvimento é construída em alguns conceitos:
- Inversão de Dependência
- Strategy Pattern
- Testabilidade
Esses três "mantras" se mostraram inestimáveis em minha jornada como desenvolvedor, tornando-me mais eficiente e criando uma relação amigável com a dívida técnica.
Inversão de Dependência
A Inversão de Dependência forma a espinha dorsal do meu estilo de desenvolvimento. Ela me permite dividir meu código em funcionalidades menores e gerenciáveis. Essa separação me permite controlar quais partes do meu código acumularão dívida técnica. Normalmente é uma quantidade pequena e gerenciável que pode ser paga prontamente.
Considere o seguinte exemplo em Swift:
protocol VendingItem {
var price: Int { get }
var description: String { get }
}
protocol StockManager {
async func items() -> [VendingItem]
async func select(item: VendingItem) -> VendingItem
}
protocol CoinStorage {
async func insertCoin(coin value: Int)
async func withdraw(coin value: Int)
async func totalInserted() -> Int
}
protocol CashierDelegate: AnyObject {
func paymentSuccess(_ cashier: Cashier,
item: VendingItem,
backValue: Int)
}
protocol Cashier {
async func tryToPay(item: VendingItem,
withdrawValue: Int) -> Bool
}
protocol MachineScreen {
async func write(_ message: String)
}
protocol MachineEngine {
async func release(item: VendingItem)
}
class VendingMachine {
private let stock: StockManager
private let coinStorage: CoinStorage
private let cashier: Cashier
private let engine: MachineEngine
private let screen: MachineScreen
init(stock: StockManager,
coinStorage: CoinStorage,
cashier: Cashier,
engine: MachineEngine,
screen: MachineScreen) {
self.stock = stock
self.coinStorage = coinStorage
self.cashier = cashier
self.cashier.delegate = self
self.engine = engine
self.screen = screen
}
func insertCoin(value: Int) {
await self.coinStorage.insertCoin(value)
let totalInsertedCoins = await self.coinStorage.totalInserted()
let newMessage = "Inserted total \(totalInsertedCoins)"
await self.screen.write(newMessage)
}
func selectVendingItem(item: VendingItem) {
let vendingItem = await self.stock.select(item: item)
let vendingItemPrice = vendingItem.price
let totalInsertedCoins = await self.coinStorage.totalInserted()
guard totalInsertedCoins >= vendingItem.price else {
let difference = vendingItemPrice - totalInsertedCoins
self.screen.write("Need more $\(difference)")
return
}
self.cashier.tryToPay(vendingItem, totalInsertedCoins)
}
}
extension VendingMachine: CashierDelegate {
func paymentSuccess(_ cashier: Cashier, item: VendingItem, backValue: Int) {
self.screen.write("Releasing \(item.description)")
self.coinStorage.withdraw(backValue)
self.engine.release(item)
}
}
Neste exemplo simples, o princípio da inversão de dependência me permite codificar o fluxo de forma eficiente. Ao examinar este código, fica claro quais componentes podem suportar a carga da futura dívida técnica. Além disso, quase todas as partes do fluxo são testáveis. A beleza dessa abordagem é sua flexibilidade - ela oferece a capacidade de escolher onde incorrer em dívida técnica e permite um fácil pagamento quando necessário.
Esta estratégia não incentiva práticas de codificação irresponsáveis. Em vez disso, reconhece que em algumas instâncias, a acumulação de dívida técnica não é apenas inevitável, mas também estratégica. É fundamental entender o impacto potencial, gerenciá-lo efetivamente e garantir que não saia do controle. Em última análise, a dívida técnica pode ser uma ferramenta poderosa para os desenvolvedores que sabem como usá-la.
Strategy Pattern
O Strategy Pattern forma outra pedra angular da minha filosofia de programação, ao lado da Inversão de Dependência e Testabilidade. Ele facilita a seleção de um algoritmo em tempo de execução. Isso significa que ele pode fornecer a flexibilidade para escolher diferentes estratégias para diferentes situações.
Vamos ver como podemos integrar o Strategy Pattern no exemplo da máquina de venda automática, especificamente no sistema de pagamento. Vamos adicionar um protocolo PaymentStrategy
e estratégias concretas CashPaymentStrategy
e CardPaymentStrategy
.
protocol PaymentStrategy {
async func pay(_ amount: Int) -> Bool
}
class CashPaymentStrategy: PaymentStrategy {
let coinStorage: CoinStorage
init(coinStorage: CoinStorage) {
self.coinStorage = coinStorage
}
async func pay(_ amount: Int) -> Bool {
await coinStorage.withdraw(coin: amount)
return true
}
}
class CardPaymentStrategy: PaymentStrategy {
async func pay(_ amount: Int) -> Bool {
// Perform card payment here.
return true
}
}
protocol Cashier {
var paymentStrategy: PaymentStrategy? { get set }
async func tryToPay(item: VendingItem,
withdrawValue: Int) -> Bool
}
class VendingMachineCashier: Cashier {
var paymentStrategy: PaymentStrategy?
async func tryToPay(item: VendingItem,
withdrawValue: Int) -> Bool {
guard let paymentStrategy = paymentStrategy else {
return false
}
return await paymentStrategy.pay(withdrawValue)
}
}
class VendingMachine {
// ...
private let cashier: VendingMachineCashier
init(stock: StockManager,
coinStorage: CoinStorage,
cashier: VendingMachineCashier,
engine: MachineEngine,
screen: MachineScreen) {
// ...
self.cashier = cashier
}
func selectPaymentMethod(method: PaymentStrategy) {
self.cashier.paymentStrategy = method
}
// ...
}
Dessa forma, você pode selecionar o método de pagamento antes de fazer um pagamento, usando o método selectPaymentMethod
:
let vendingMachine = VendingMachine(stock: ...,
coinStorage: ...,
cashier: ...,
engine: ...,
screen: ...)
vendingMachine.selectPaymentMethod(method: CashPaymentStrategy(coinStorage: ...))
vendingMachine.selectVendingItem(id: ...)
Essa adaptação do Strategy Pattern permite que a máquina de venda automática aceite diferentes tipos de pagamento, tornando-a mais versátil. Essa flexibilidade é especialmente útil em um ambiente de desenvolvimento real, onde os requisitos e as situações muitas vezes variam.
Em essência, o Strategy Pattern é uma ferramenta poderosa que, quando combinada com a Inversão de Dependência e a Testabilidade, permite aos desenvolvedores escrever um código flexível, mantível e que possa lidar com mudanças e complexidades.
Testabilidade
O último conceito que forma a espinha dorsal da minha filosofia de desenvolvimento é a Testabilidade. Escrever código que seja fácil de testar nos permite verificar rapidamente se nosso código funciona como esperado e identificar quaisquer erros ou inconsistências. Além disso, um código que é fácil de testar geralmente é bem estruturado e modular, tornando-o mais fácil de manter e estender.
Quando o código segue o Princípio da Inversão de Dependência e usa o Strategy Pattern, testá-lo se torna consideravelmente mais fácil. As dependências podem ser substituídas por implementações simuladas nos testes, e diferentes estratégias podem ser trocadas para verificar diferentes aspectos do comportamento.
Considere nosso exemplo da Máquina de Venda Automática. Já definimos vários componentes que podem ser testados independentemente, como StockManager, CoinStorage e Cashier. Poderíamos escrever testes para esses componentes assim:
class VendingMachineTests: XCTestCase {
// Mocks
let mockStockManager = MockStockManager()
let mockCoinStorage = MockCoinStorage()
let mockCashier = MockCashier()
let mockEngine = MockMachineEngine()
let mockScreen = MockMachineScreen()
var vendingMachine: VendingMachine!
override func setUp() {
super.setUp()
// Create VendingMachine with mock dependencies
vendingMachine = VendingMachine(stock: mockStockManager,
coinStorage: mockCoinStorage,
cashier: mockCashier,
engine: mockEngine,
screen: mockScreen)
}
func testInsertCoin() {
// Simulate inserting a coin
vendingMachine.insertCoin(value: 10)
// Assert that the coin storage and screen updated correctly
XCTAssertEqual(mockCoinStorage.totalInserted, 10)
XCTAssertEqual(mockScreen.lastMessage, "Inserted total 10")
}
func testSelectVendingItem() {
// Simulate selecting a vending item
let item = VendingItem(price: 10, description: "Candy")
mockStockManager.selectedItem = item
// Assert that the cashier was asked to try to pay
vendingMachine.selectVendingItem(id: 0)
XCTAssertEqual(mockCashier.lastItem, item)
}
}
Estes são exemplos simples, mas eles demonstram o poder de tornar o código testável. Quando seu código é testável, você pode garantir que cada pedaço está funcionando como esperado, independentemente dos outros.
Em suma, a testabilidade é uma ferramenta poderosa que, quando combinada com a inversão de dependência e o strategy pattern, capacita os desenvolvedores a escrever um código flexível, fácil de manter e resistente a mudanças e complexidades.
Conclusão
Como desenvolvedores, a dívida técnica é algo com o qual inevitavelmente teremos que lidar. No entanto, entendendo-a e sabendo como gerenciá-la de maneira estratégica, podemos transformar o que é geralmente visto como um aspecto negativo em uma ferramenta poderosa.
Com a Inversão de Dependência, o Strategy Pattern e a Testabilidade, somos capazes de escrever um código que é fácil de manter e extender, que é resistente a mudanças e complexidades, e que nos permite verificar sua funcionalidade e robustez a qualquer momento. Assim, embora possamos ter que acumular alguma dívida técnica de vez em quando, ela não precisa ser uma espiral fora de controle.
Em vez disso, a dívida técnica pode ser uma escolha estratégica que, quando gerenciada efetivamente, pode realmente ajudar a acelerar o desenvolvimento e garantir a entrega de produtos de qualidade. Ao final do dia, a dívida técnica é uma ferramenta poderosa para aqueles que sabem como usá-la.