Чистая Архитектура: Недостижимый Идеал
simprl
Posted on October 8, 2024
Начало пути
В горах Тибета, в уединенном монастыре, жил молодой Ученик, стремящийся постичь глубины программирования и достичь гармонии в своём коде. Он мечтал создать приложение, которое отражало бы принципы Чистой Архитектуры. Однажды он решил обратиться к мудрому Мастеру за советом.
Ученик подошёл к Мастеру и спросил:
Ученик: "О, мудрый Мастер, я создал приложение для управления покупками. Моя архитектура чиста?"
Мастер: "Покажи мне своё творение, и мы вместе узнаем истину."
Ученик продемонстрировал свой код, где база данных и сценарий использования были объединены.
Код Ученика:
// app.ts
import sqlite3 from 'sqlite3';
import { open, Database } from 'sqlite';
interface Purchase {
id: number;
title: string;
cost: number;
}
async function initializeDatabase(): Promise<Database> {
const db = await open({
filename: ':memory:',
driver: sqlite3.Database,
});
await db.exec(`
CREATE TABLE purchases (
id INTEGER PRIMARY KEY,
title TEXT,
cost REAL
)
`);
return db;
}
async function addPurchaseIfCan(db: Database, purchase: Purchase): Promise<void> {
const { id, title, cost } = purchase;
const row = await db.get<{ totalCost: number }>(
`SELECT SUM(cost) as totalCost FROM purchases WHERE title = ?`,
[title]
);
const totalCost = row?.totalCost || 0;
const newTotalCost = totalCost + cost;
if (newTotalCost < 99999) {
await db.run(
`INSERT INTO purchases (id, title, cost) VALUES (?, ?, ?)`,
[id, title, cost]
);
console.log('Покупка успешно добавлена.');
} else {
console.log('Общая стоимость превышает 99999.');
}
}
(async () => {
const db = await initializeDatabase();
await addPurchaseIfCan(db, { id: 3, title: 'рис', cost: 2 });
})();
Мастер, после изучения кода, задумчиво произнес:
Мастер: "Твой код подобен реке, где смешаны чистые и мутные воды. Бизнес-логика и детали переплетены. Чтобы достичь истинной чистоты архитектуры, раздели их, как небо и землю."
Первые шаги к чистой архитектуре
Поняв наставление, Ученик решил разделить код на уровни, выделяя базу данных и сценарий использования в отдельные модули. Он также ввёл интерфейсы, чтобы следовать принципу инверсии зависимостей, который является краеугольным камнем Чистой Архитектуры. Теперь addPurchaseIfCan будет зависеть от интерфейса, а не от конкретной реализации репозитория.
// app.ts
import { initializeDatabase } from './db/init';
import { PurchaseRepository } from './db/purchaseRepository';
import { addPurchaseIfCan } from './useCases/addPurchaseIfCan';
(async () => {
const db = await initializeDatabase();
const purchaseRepository = new PurchaseRepository(db);
await addPurchaseIfCan(purchaseRepository, { id: 3, title: 'рис', cost: 2 });
})();
// useCases/addPurchaseIfCan.ts
import { IPurchaseRepository, Purchase } from './IPurchaseRepository';
export async function addPurchaseIfCan(
purchaseRepository: IPurchaseRepository,
purchase: Purchase
): Promise<void> {
const { id, title, cost } = purchase;
const totalCost = await purchaseRepository.getTotalCostByTitle(title);
const newTotalCost = totalCost + cost;
if (newTotalCost < 99999) {
await purchaseRepository.add(purchase);
console.log('Покупка успешно добавлена.');
} else {
console.log('Общая стоимость превышает 99999.');
}
}
// useCases/IPurchaseRepository.ts
export interface IPurchaseRepository {
add(purchase: Purchase): Promise<Purchase>;
getTotalCostByTitle(title: string): Promise<number>;
}
export interface Purchase {
id: number;
title: string;
cost: number;
}
// db/init.ts
import sqlite3 from 'sqlite3';
import { open, Database } from 'sqlite';
export async function initializeDatabase(): Promise<Database> {
const db = await open({
filename: ':memory:',
driver: sqlite3.Database,
});
await db.exec(`
CREATE TABLE purchases (
id INTEGER PRIMARY KEY,
title TEXT,
cost REAL
)
`);
return db;
}
// db/purchaseRepository.ts
import { Database } from 'sqlite';
import { IPurchaseRepository, Purchase } from 'useCases/IPurchaseRepository';
export class PurchaseRepository implements IPurchaseRepository {
private db: Database;
constructor(db: Database) {
this.db = db;
}
async add(purchase: Purchase): Promise<Purchase> {
const { id, title, cost } = purchase;
await this.db.run(
`INSERT INTO purchases (id, title, cost) VALUES (?, ?, ?)`,
[id, title, cost]
);
return purchase;
}
async getTotalCostByTitle(title: string): Promise<number> {
const row = await this.db.get<{ totalCost: number }>(
`SELECT SUM(cost) as totalCost FROM purchases WHERE title = ?`,
[title]
);
const totalCost = row?.totalCost || 0;
return totalCost;
}
}
Ученик вернулся к Мастеру и спросил:
Ученик: "Я разделил свой код на уровни, выделив базу данных и сценарий использования в отдельные модули, и использовал интерфейсы для репозитория. Моя архитектура стала чище?"
Мастер, глядя на код, ответил:
Мастер: "Ты сделал шаг вперёд, но вычисление totalCost всё ещё происходит в инфраструктурном слое. Однако totalCost относится больше к бизнес-логике твоего сценария использования. Перенеси это вычисление внутрь сценария использования, чтобы отделить бизнес-правила от деталей хранения данных."
Осознание разделения
Ученик осознал, что totalCost должен быть частью бизнес-логики. Он изменил код, чтобы получать список покупок и вычислять totalCost в сценарии использования.
// useCases/IPurchaseRepository.ts
export interface IPurchaseRepository {
add(purchase: Purchase): Promise<Purchase>;
getPurchasesByTitle(title: string): Promise<Purchase[]>;
}
...
// db/purchaseRepository.ts
import { Database } from 'sqlite';
import { IPurchaseRepository } from './IPurchaseRepository';
export class PurchaseRepository implements IPurchaseRepository {
...
async getPurchasesByTitle(title: string): Promise<Purchase[]> {
const rows = await this.db.all<Purchase[]>(
`SELECT * FROM purchases WHERE title = ?`,
[title]
);
return rows.map((row) => ({
id: row.id,
title: row.title,
cost: row.cost,
}));
}
}
// useCases/addPurchaseIfCan.ts
import { IPurchaseRepository, Purchase } from './IPurchaseRepository';
export async function addPurchaseIfTotalCostLessThanLimit(
purchaseRepository: IPurchaseRepository,
purchaseData: Purchase,
limit: number
): Promise<void> {
const { id, title, cost } = purchaseData;
const purchases = await purchaseRepository.getPurchasesByTitle(title);
let totalCost = 0;
for (const purchase of purchases) {
totalCost += purchase.cost;
}
const newTotalCost = totalCost + cost;
if (newTotalCost < limit) {
await purchaseRepository.add(purchaseData);
console.log('Покупка успешно добавлена.');
} else {
console.log(`Общая стоимость превышает ${limit}.`);
}
}
Ученик снова подошёл к Мастеру:
Ученик: "Я перенёс вычисление totalCost в сценарий использования и отделил бизнес-логику от инфраструктуры. Моя архитектура стала чище?"
Мастер, с теплотой в голосе, сказал:
Мастер: "Ты сделал значительный прогресс, но арифметические операции могут приводить к неточностям. При работе с десятичными числами обычные операции JavaScript могут быть ненадёжными."
Встреча с деталями реализации
Ученик понял, что работа с числами в JavaScript может вызывать ошибки из-за особенностей представления чисел с плавающей точкой. Он обновил код, используя decimal.js для точных вычислений.
// useCases/addPurchaseIfCan.ts
import Decimal from 'decimal.js';
import { IPurchaseRepository, Purchase } from './IPurchaseRepository';
export async function addPurchaseIfCan(
purchaseRepository: IPurchaseRepository,
purchaseData: Purchase,
limit: number
): Promise<void> {
const { id, title, cost } = purchaseData;
const purchases = await purchaseRepository.getPurchasesByTitle(title);
let totalCost = new Decimal(0);
for (const purchase of purchases) {
totalCost = totalCost.plus(purchase.cost);
}
const newTotalCost = totalCost.plus(cost);
if (newTotalCost.greaterThanOrEqualTo(limit)) {
console.log(`Общая стоимость превышает ${limit}.`);
} else {
await purchaseRepository.add(purchaseData);
console.log('Покупка успешно добавлена.');
}
}
Ученик вернулся к Мастеру:
Ученик: "Я скорректировал арифметические операции с помощью decimal.js, чтобы избежать неточностей. Моя архитектура стала чище?"
Мастер ответил:
Мастер: "Ты проделал хорошую работу, но твой сценарий использования всё ещё содержит детали реализации. Прямая зависимость от decimal.js привязывает бизнес-логику к конкретной библиотеке. Если ты захочешь изменить библиотеку в будущем, тебе придётся менять бизнес-логику."
Инверсия зависимостей
Понимая проблему, Ученик решил абстрагировать арифметические операции, используя инверсию зависимостей, чтобы бизнес-логика не зависела от конкретной реализации.
// useCases/calculator.ts
export abstract class Calculator {
abstract add(a: string, b: string): string;
abstract greaterThanOrEqual(a: string, b: string): boolean;
}
// decimalCalculator.ts
import Decimal from 'decimal.js';
import { Calculator } from 'useCases/calculator';
export class DecimalCalculator extends Calculator {
add(a: string, b: string): string {
return new Decimal(a).plus(new Decimal(b)).toString();
}
greaterThanOrEqual(a: string, b: string): boolean {
return new Decimal(a).greaterThanOrEqualTo(new Decimal(b));
}
}
// addPurchaseIfCan.ts
import { IPurchaseRepository, Purchase } from './IPurchaseRepository';
import { Calculator } from 'useCases/calculator';
export class addPurchaseIfCan {
private purchaseRepository: IPurchaseRepository;
private calculator: Calculator;
private limit: string;
constructor(
purchaseRepository: IPurchaseRepository,
calculator: Calculator,
limit: number
) {
this.purchaseRepository = purchaseRepository;
this.calculator = calculator;
this.limit = limit.toString();
}
async execute(purchaseData: Purchase): Promise<void> {
const { id, title, cost } = purchaseData;
const purchases = await this.purchaseRepository.getPurchasesByTitle(title);
let totalCost = '0';
for (const purchase of purchases) {
totalCost = this.calculator.add(totalCost, purchase.cost.toString());
}
const newTotalCost = this.calculator.add(totalCost, cost.toString());
if (this.calculator.greaterThanOrEqual(newTotalCost, this.limit)) {
console.log(`Общая стоимость превышает ${this.limit}.`);
} else {
await this.purchaseRepository.add({
id,
title,
cost: parseFloat(cost.toString()),
});
console.log('Покупка успешно добавлена.');
}
}
}
// app.ts
import { initializeDatabase } from './db/init';
import { PurchaseRepository } from './db/purchaseRepository';
import { AddPurchaseIfCan } from './AddPurchaseIfCan';
import { DecimalCalculator } from './decimalCalculator';
(async () => {
const db = await initializeDatabase();
const purchaseRepository = new PurchaseRepository(db);
const calculator = new DecimalCalculator();
const limit = 99999;
const addPurchaseUseCase = new AddPurchaseIfCan(
purchaseRepository,
calculator,
limit
);
await addPurchaseUseCase.execute({ id: 3, title: 'рис', cost: 2 });
})();
Ученик снова обратился к Мастеру:
Ученик: "Я абстрагировал арифметические операции с помощью инверсии зависимостей. Теперь моя архитектура чиста?"
Мастер ответил:
Мастер: "Ты сделал значительный прогресс. Но помни, что твои сценарии использования всё ещё зависят от деталей языка программирования. Ты используешь Javascript и Typescript, но если эти технологии перестанут быть актуальными, то тебе прийдется полностью все переписать на другой язык."
Принятие и понимание
Ученик в недоумении задумался, а потом спросил:
Ученик: "Мастер, как же мне достичь идеальной чистоты архитектуры, если мои сценарии использования всё ещё зависят от языка программирования?"
Мастер, улыбаясь, ответил:
Мастер: "Как птица не может отделиться от неба, так и архитектура не может быть полностью независимой от среды. Полная независимость невозможна, но стремление к ней обогащает твою архитектуру. Цель Чистой Архитектуры — создать систему, где изменения могут быть внесены с минимальными усилиями, и где бизнес-логика отделена от деталей реализации. Понимание этого баланса — ключ к истинной мудрости."
Ученик почувствовал просветление и сказал:
Ученик: "Благодарю тебя, Мастер. Теперь я понимаю, что совершенство не в абсолютной изоляции, а в гармоничном разделении ответственности."
Мастер: "Иди с миром, Ученик. Твой путь только начинается, но ты уже нашёл направление."
Эпилог
Спустя некоторое время, Ученик заметил, что его приложение стало работать медленнее. Он был озадачен: почему программа, которая раньше работала быстро, теперь еле справляется со своей задачей?
Оказалось, что это вовсе не из-за того, что исходный код увеличился в 3 раза, а из-за того, что вычисление totalCost выполняется не в базе данных. Приложение тратило много ресурсов на пересылку больших объёмов данных из базы данных в приложение и на их обработку. Если бы расчёт происходил непосредственно в базе данных, не требовалось бы передавать тысячи строк между слоями, что значительно ускорило бы процесс.
Ученик хотел обсудить это с Мастером, но тот куда-то пропал, и вопрос остался без ответа.
Увидев пустой монастырь, Ученик достал новую книгу и сказал: "Похоже, мой путь к просветлению привёл меня к новому испытанию — оптимизации производительности."
Posted on October 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.