Εξαγωνική Αρχιτεκτονική | Πρακτικός Οδηγός για Σύγχρονες Εφαρμογές
Μάθετε τι είναι η εξαγωνική αρχιτεκτονική, γιατί έχει σημασία και πώς να την υλοποιήσετε. Πλήρης οδηγός με πραγματικά παραδείγματα για κατασκευή συντηρήσιμου, ελεγχόμενου λογισμικού.
Η εξαγωνική αρχιτεκτονική είναι ένα μοτίβο σχεδιασμού λογισμικού που απομονώνει τη βασική επιχειρηματική λογική σας από εξωτερικά συστήματα όπως βάσεις δεδομένων, APIs και διεπαφές χρήστη. Η ιδέα είναι απλή: ο κώδικας domain δεν πρέπει ποτέ να εξαρτάται από υποδομή. Η υποδομή εξαρτάται από το domain.
Το μοτίβο εισήχθη από τον Alistair Cockburn το 2005. Μπορεί επίσης να το ακούσετε ως “Ports and Adapters.” Και τα δύο ονόματα περιγράφουν την ίδια ιδέα.
Γιατί Υπάρχει
Οι περισσότερες εφαρμογές ξεκινούν με τον ίδιο τρόπο. Η επιχειρηματική λογική μπλέκεται με ερωτήματα βάσης δεδομένων, HTTP handlers και κλήσεις υπηρεσιών τρίτων. Λειτουργεί στην αρχή. Μετά ο κώδικας μεγαλώνει, και συμβαίνουν τρία πράγματα:
- Ο έλεγχος γίνεται επώδυνος. Δεν μπορείτε να ελέγξετε επιχειρηματικούς κανόνες χωρίς να σηκώσετε βάση δεδομένων ή να κάνετε mock μισό framework.
- Η αλλαγή υποδομής είναι ριψοκίνδυνη. Η αντικατάσταση παρόχου πληρωμών ή η μετεγκατάσταση από REST σε GraphQL σημαίνει ξαναγράψιμο επιχειρηματικής λογικής.
- Ο κώδικας είναι δύσκολο να κατανοηθεί. Κανείς δεν μπορεί να πει πού τελειώνει το domain και πού αρχίζει η υποδομή.
Η εξαγωνική αρχιτεκτονική λύνει και τα τρία προβλήματα επιβάλλοντας έναν μοναδικό κανόνα: οι εξαρτήσεις δείχνουν πάντα προς τα μέσα.
Οι Βασικές Έννοιες
Σκεφτείτε την εφαρμογή σας ως τρία ομόκεντρα επίπεδα.
Το Domain (Κέντρο)
Αυτή είναι η επιχειρηματική σας λογική. Καθαρές συναρτήσεις, entities, value objects και domain services. Έχει μηδέν εξαρτήσεις από frameworks, βάσεις δεδομένων ή εξωτερικές βιβλιοθήκες. Μιλά τη δική του γλώσσα.
Για ένα e-commerce σύστημα, αυτό το επίπεδο περιέχει concepts όπως Order, Product, PricingRule και InventoryPolicy. Αυτά τα αντικείμενα δεν γνωρίζουν τίποτα για SQL, HTTP ή JSON.
Ports (Μεσαίο Επίπεδο)
Τα ports είναι interfaces που ορίζουν πώς ο εξωτερικός κόσμος αλληλεπιδρά με το domain. Είναι contracts, όχι υλοποιήσεις.
Υπάρχουν δύο τύποι:
- Inbound ports (driving). Ορίζουν τι μπορεί να κάνει η εφαρμογή. Σκεφτείτε τα ως use cases. “Κάνε μια παραγγελία.” “Ακύρωσε μια συνδρομή.” “Δημιούργησε ένα τιμολόγιο.”
- Outbound ports (driven). Ορίζουν τι χρειάζεται η εφαρμογή από τον εξωτερικό κόσμο. “Αποθήκευσε μια παραγγελία.” “Στείλε ειδοποίηση.” “Φέρε τη συναλλαγματική ισοτιμία.”
Τα ports ανήκουν στο domain layer. Γράφονται σε γλώσσα domain, όχι γλώσσα υποδομής. Γράφετε OrderRepository, όχι PostgresOrderDAO.
Adapters (Εξωτερικό Επίπεδο)
Οι adapters είναι οι συγκεκριμένες υλοποιήσεις που συνδέουν τον πραγματικό κόσμο με τα ports σας.
- Inbound adapters μεταφράζουν εξωτερικά αιτήματα σε κλήσεις domain. Ένας REST controller, ένας GraphQL resolver, μια CLI command, ένας consumer ουράς μηνυμάτων. Όλα αυτά είναι inbound adapters.
- Outbound adapters υλοποιούν τα outbound port interfaces. Ένα PostgreSQL repository, ένας SMTP email sender, ένα Stripe payment gateway. Όλα αυτά είναι outbound adapters.
Η βασική ιδέα: οι adapters εξαρτώνται από τα ports. Τα ports δεν εξαρτώνται ποτέ από τους adapters. Αυτό κάνει ολόκληρο το μοτίβο να λειτουργεί.
Ένα Συγκεκριμένο Παράδειγμα
Ας πούμε ότι φτιάχνετε μια λειτουργία τοποθέτησης παραγγελίας. Ορίστε πώς χωρίζονται τα επίπεδα.
Domain (entities και κανόνες):
// domain/Order.ts
class Order {
readonly id: string;
readonly items: OrderItem[];
readonly status: OrderStatus;
calculateTotal(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity)),
Money.zero()
);
}
confirm(): Order {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
return new Order({ ...this, status: "confirmed" });
}
}
Inbound port (use case interface):
// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}
Outbound ports (τι χρειάζεται το use case):
// ports/outbound/OrderRepository.ts
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
// ports/outbound/PaymentGateway.ts
interface PaymentGateway {
charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>;
}
// ports/outbound/NotificationSender.ts
interface NotificationSender {
sendOrderConfirmation(order: Order): Promise<void>;
}
Application service (υλοποιεί το inbound port):
// application/PlaceOrderService.ts
class PlaceOrderService implements PlaceOrder {
constructor(
private orders: OrderRepository,
private payments: PaymentGateway,
private notifications: NotificationSender
) {}
async execute(command: PlaceOrderCommand): Promise<OrderConfirmation> {
const order = Order.create(command.items);
const confirmed = order.confirm();
const payment = await this.payments.charge(
confirmed.calculateTotal(),
command.paymentMethod
);
if (!payment.successful) {
throw new PaymentFailedError(payment.reason);
}
await this.orders.save(confirmed);
await this.notifications.sendOrderConfirmation(confirmed);
return OrderConfirmation.from(confirmed, payment);
}
}
Adapters (υποδομή):
// adapters/inbound/OrderController.ts
class OrderController {
constructor(private placeOrder: PlaceOrder) {}
async handlePost(req: Request): Promise<Response> {
const command = PlaceOrderCommand.fromRequest(req.body);
const confirmation = await this.placeOrder.execute(command);
return Response.json(confirmation.toResponse(), { status: 201 });
}
}
// adapters/outbound/PostgresOrderRepository.ts
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await this.db.query(
"INSERT INTO orders (id, items, status) VALUES ($1, $2, $3)",
[order.id, JSON.stringify(order.items), order.status]
);
}
async findById(id: string): Promise<Order | null> {
const row = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
return row ? Order.fromPersistence(row) : null;
}
}
Παρατηρήστε πώς το PlaceOrderService δεν αναφέρει ποτέ PostgreSQL, HTTP ή οποιοδήποτε framework. Λειτουργεί αποκλειστικά με domain concepts και port interfaces.
Ο Κανόνας Εξαρτήσεων
Αυτός είναι ο πιο σημαντικός κανόνας. Αν δεν πάρετε τίποτα άλλο από αυτό το άρθρο, πάρτε αυτό:
Οι εξαρτήσεις πηγαίου κώδικα πρέπει πάντα να δείχνουν προς τα μέσα, προς το domain.
- Οι adapters εξαρτώνται από τα ports. Ποτέ το αντίθετο.
- Τα ports εξαρτώνται από domain entities. Ποτέ από adapters.
- Το domain δεν εξαρτάται από τίποτα εξωτερικό.
Αυτό επιβάλλεται μέσω dependency injection. Το composition root (το σημείο εισόδου της εφαρμογής) συνδέει τα πάντα μαζί:
// main.ts (composition root)
const orderRepo = new PostgresOrderRepository(db);
const payments = new StripePaymentGateway(stripeClient);
const notifications = new EmailNotificationSender(mailer);
const placeOrder = new PlaceOrderService(orderRepo, payments, notifications);
const controller = new OrderController(placeOrder);
Το domain layer δεν κάνει ποτέ import από το adapters layer. Αν δείτε import από adapters/ μέσα στο domain/ ή ports/, κάτι είναι λάθος.
Δομή Project
Μια καθαρή δομή φακέλων κάνει τα όρια ορατά:
src/
domain/
Order.ts
OrderItem.ts
Money.ts
errors/
EmptyOrderError.ts
PaymentFailedError.ts
ports/
inbound/
PlaceOrder.ts
CancelOrder.ts
outbound/
OrderRepository.ts
PaymentGateway.ts
NotificationSender.ts
application/
PlaceOrderService.ts
CancelOrderService.ts
adapters/
inbound/
http/
OrderController.ts
cli/
PlaceOrderCommand.ts
outbound/
persistence/
PostgresOrderRepository.ts
payment/
StripePaymentGateway.ts
notification/
EmailNotificationSender.ts
main.ts
Κάθε developer στην ομάδα μπορεί να κοιτάξει αυτό το δέντρο και να καταλάβει πού να βάλει νέο κώδικα.
Οφέλη Testing
Εδώ η εξαγωνική αρχιτεκτονική πραγματικά αποδίδει.
Unit testing του domain:
test("order calculates total correctly", () => {
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 3 },
{ product: "Gadget", price: Money.eur(25), quantity: 1 },
]);
expect(order.calculateTotal()).toEqual(Money.eur(55));
});
Χωρίς βάση δεδομένων. Χωρίς HTTP server. Χωρίς mocking framework. Καθαρή λογική, καθαρά tests.
Testing use cases με fake adapters:
test("place order charges payment and saves", async () => {
const orders = new InMemoryOrderRepository();
const payments = new FakePaymentGateway({ alwaysSucceeds: true });
const notifications = new FakeNotificationSender();
const service = new PlaceOrderService(orders, payments, notifications);
const result = await service.execute({
items: [{ product: "Widget", price: 10, quantity: 2 }],
paymentMethod: { type: "card", token: "tok_test" },
});
expect(result.status).toBe("confirmed");
expect(orders.savedOrders).toHaveLength(1);
expect(payments.charges).toHaveLength(1);
expect(notifications.sent).toHaveLength(1);
});
Ελέγχετε ολόκληρο το use case χωρίς να αγγίζετε πραγματική υποδομή. Οι fake adapters υλοποιούν τα ίδια port interfaces, οπότε είναι εναλλάξιμοι με τους πραγματικούς.
Integration tests μόνο για adapters:
test("PostgresOrderRepository saves and retrieves", async () => {
const repo = new PostgresOrderRepository(testDb);
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 1 },
]);
await repo.save(order.confirm());
const found = await repo.findById(order.id);
expect(found).toEqual(order.confirm());
});
Κάθε adapter παίρνει το δικό του integration test. Αν ο adapter περνά το test του, και το use case περνά με fake adapters, ολόκληρο το σύστημα λειτουργεί.
Πότε να Χρησιμοποιήσετε Εξαγωνική Αρχιτεκτονική
Η εξαγωνική αρχιτεκτονική προσθέτει δομή και indirection. Αυτό έχει κόστος. Ορίστε πότε αξίζει:
- Εφαρμογές μακράς διάρκειας. Αν το project θα συντηρείται για χρόνια, η επένδυση αποδίδει γρήγορα.
- Πολύπλοκοι επιχειρηματικοί κανόνες. Αν το domain σας έχει μη τετριμμένη λογική που χρειάζεται σχολαστικό testing.
- Πολλαπλά σημεία εισόδου. Αν η ίδια λογική καλείται από web API, CLI, ουρά μηνυμάτων και cron job.
- Η υποδομή μπορεί να αλλάξει. Αν δεν είστε σίγουροι αν θα μείνετε με την τρέχουσα βάση δεδομένων, cloud πάροχο ή υπηρεσίες τρίτων.
- Πολλαπλοί developers. Σαφή όρια μειώνουν τις συγκρούσεις merge και κάνουν τα code reviews ταχύτερα.
Πότε να τη Παραλείψετε
Δεν χρειάζεται κάθε project αυτό το επίπεδο δομής:
- Απλές CRUD εφαρμογές. Αν η εφαρμογή κυρίως διαβάζει και γράφει δεδομένα με ελάχιστη επιχειρηματική λογική, το indirection προσθέτει κόστος χωρίς όφελος.
- Πρωτότυπα και MVPs. Η ταχύτητα μετρά περισσότερο από την αρχιτεκτονική όταν επικυρώνετε μια ιδέα. Κάντε refactor αργότερα.
- Μικρά scripts και εργαλεία. Ένα CLI tool 200 γραμμών δεν χρειάζεται ports και adapters.
Η σωστή ποσότητα αρχιτεκτονικής εξαρτάται από την πολυπλοκότητα του προβλήματος. Ξεκινήστε απλά. Εισάγετε εξαγωνικά όρια όταν ο πόνος της απουσίας τους γίνει πραγματικός.
Συνηθισμένα Λάθη
Διαρροή υποδομής στο domain. Αν το Order entity σας έχει decorator @Column ή μέθοδο toJSON(), η υποδομή έχει διαρρεύσει. Κρατήστε τα domain objects σας καθαρά.
Δημιουργία ports που αντικατοπτρίζουν υποδομή. Ένα port που ονομάζεται SqlQuery αναιρεί τον σκοπό. Τα ports πρέπει να περιγράφουν τι χρειάζεται το domain σε γλώσσα domain, όχι πώς λειτουργεί η υποδομή.
Υπερβολική αφαίρεση. Δεν χρειάζεται τα πάντα ένα port. Αν έχετε μια utility function που μορφοποιεί ημερομηνίες, απλά χρησιμοποιήστε τη. Κρατήστε τα ports για πραγματικά όρια υποδομής.
Παράλειψη του composition root. Χωρίς σαφές σημείο όπου οι εξαρτήσεις συνδέονται μαζί, το μοτίβο καταρρέει. Κάθε εξάρτηση πρέπει να εγχέεται, όχι να εισάγεται άμεσα.
Εξαγωνική vs Άλλα Μοτίβα
Μπορεί να αναρωτιέστε πώς σχετίζεται η εξαγωνική αρχιτεκτονική με άλλα γνωστά μοτίβα:
- Clean Architecture (Robert C. Martin): Πολύ παρόμοια. Η Clean Architecture προσθέτει περισσότερα επίπεδα (entities, use cases, interface adapters, frameworks) αλλά η βασική ιδέα είναι η ίδια: οι εξαρτήσεις δείχνουν προς τα μέσα.
- Onion Architecture (Jeffrey Palermo): Επίσης πολύ παρόμοια. Η Onion Architecture χρησιμοποιεί ομόκεντρους δακτυλίους με το domain στο κέντρο. Η ορολογία διαφέρει, αλλά ο κανόνας εξαρτήσεων είναι ταυτόσημος.
- Layered Architecture (N-tier): Η παραδοσιακή προσέγγιση όπου τα επίπεδα στοιβάζονται κάθετα (presentation, business, data). Η κρίσιμη διαφορά: στη layered αρχιτεκτονική, το business layer τυπικά εξαρτάται από το data layer. Στην εξαγωνική, αυτή η εξάρτηση αντιστρέφεται.
Και τα τρία σύγχρονα μοτίβα (εξαγωνικό, clean, onion) μοιράζονται την ίδια θεμελιώδη αρχή. Το domain δεν εξαρτάται από υποδομή. Διαφέρουν κυρίως στις συμβάσεις ονομασίας και τον αριθμό επιπέδων που ορίζουν.
Ξεκινώντας
Αν θέλετε να εφαρμόσετε εξαγωνική αρχιτεκτονική σε υπάρχον project, μην προσπαθήσετε να κάνετε refactor τα πάντα ταυτόχρονα. Ξεκινήστε με ένα bounded context ή ένα feature:
- Αναγνωρίστε τη βασική επιχειρηματική λογική σε αυτό το feature. Εξάγετέ τη σε καθαρές συναρτήσεις ή κλάσεις χωρίς εξαρτήσεις framework.
- Ορίστε ports για τις εξωτερικές εξαρτήσεις που χρησιμοποιεί αυτό το feature. Δημιουργήστε interfaces σε γλώσσα domain.
- Μετακινήστε τον υπάρχοντα κώδικα υποδομής σε adapter κλάσεις που υλοποιούν αυτά τα interfaces.
- Συνδέστε τα πάντα μαζί σε ένα composition root χρησιμοποιώντας dependency injection.
- Γράψτε tests για το domain και τα use cases χρησιμοποιώντας fake adapters.
Επαναλάβετε για το επόμενο feature. Με τον χρόνο, τα εξαγωνικά όρια θα εξαπλωθούν σε ολόκληρο τον κώδικα φυσικά.
Τελικές Σκέψεις
Η εξαγωνική αρχιτεκτονική δεν αφορά την τήρηση ενός αυστηρού template. Αφορά μία απλή ιδέα: προστατέψτε την επιχειρηματική σας λογική από το χάος του εξωτερικού κόσμου. Οι βάσεις δεδομένων αλλάζουν. Τα frameworks περνούν από τη μόδα. Τα APIs καταργούνται. Οι επιχειρηματικοί σας κανόνες πρέπει να επιβιώνουν από όλα αυτά.
Το μοτίβο λειτουργεί επειδή ευθυγραμμίζεται με τον τρόπο που το λογισμικό πραγματικά εξελίσσεται. Οι απαιτήσεις αλλάζουν συνεχώς. Η υποδομή αλλάζει περιοδικά. Αλλά οι βασικοί κανόνες της επιχείρησής σας τείνουν να είναι το πιο σταθερό μέρος του συστήματος. Χτίστε γύρω από αυτή τη σταθερότητα.
Αν φτιάχνετε ένα προϊόν που πρέπει να αντέξει, η εξαγωνική αρχιτεκτονική είναι μία από τις καλύτερες επενδύσεις που μπορείτε να κάνετε στον κώδικά σας.
Χρειάζεστε βοήθεια στον σχεδιασμό ή το refactoring της αρχιτεκτονικής της εφαρμογής σας; Επικοινωνήστε μαζί μας. Βοηθάμε ομάδες να φτιάχνουν λογισμικό που παραμένει συντηρήσιμο καθώς μεγαλώνει.