Yazı Dizisinin Orjinali
Örnek DDD projesi
Serinin diğer yazıları :
1 - Strategic Domain Driven Design (Stratejik DDD)
2 - Tactical Domain Driven Design (Taktiksel DDD)
Önceki iki makalede, stratejik ve taktiksel alan odaklı tasarım hakkında bilgi sahibi olduk. Şimdi bir domain modelini çalışan yazılıma nasıl dönüştüreceğinizi, daha spesifik olarak bunun altıgen mimariyi kullanarak nasıl yapılacağını öğrenmenin zamanı geldi.
Önceki iki makale, kod örnekleri Java ile yazılmış olsa da oldukça geneldi. Bu makaledeki pek çok teori diğer ortamlarda ve dillerde de uygulanabilir olsa da, bunu açıkça Java ve Vaadin ile yazdım.
Yine içerik, Eric Evans'ın Domain-Driven Design: Tackling Complexity in the Heart of Software and Implementing Domain-Driven Design by Vaughn Vernon kitaplarına dayanıyor ve ikisini de okumanızı şiddetle tavsiye ediyorum. Ancak önceki makalelerde de kendi düşüncelerimi, fikirlerimi ve deneyimlerimi sunmuş olsam da, bu daha da güçlü bir şekilde düşündüklerim ve inandıklarımla renkleniyor. Bununla birlikte, beni DDD ile başlatan şey Evans ve Vernon'un kitaplarıydı ve burada yazdıklarımın kitaplarda bulacaklarınızdan çok da uzak olmadığını düşünmek istiyorum.
Bu, bu makalenin ikinci versiyonu. İlkinde, port kavramını yanlış anlamıştım. Bu, minnettar olduğum bir okuyucu tarafından yapılan bir yorumda belirtildi. Şimdi bu hatayı düzelttim ve örnekleri ve diyagramları buna göre güncelledim. Bu mimari tarz ve DDD hakkındaki yorumlarım hakkındaki yorumlar her zaman memnuniyetle karşılanacaktır.
Neden Altıgen Deniyor?
Altıgen ve Geleneksel Katmanlar
Etki Alanı Modeli (Domain Model)
Uygulama Hizmetleri(Application Services)
Altıgen ve Entity-Kontrol-Sınırı(Hexagonal vs. Entity-Control-Boundary)
Durum Bilgisizlik (Statelessness)
public class MyBusinessProcess {
// Current process state
}
public interface MyApplicationService {
MyBusinessProcess performSomeStuff(MyBusinessProcess input);
MyBusinessProcess performSomeMoreStuff(MyBusinessProcess input);
}
Güvenlik yaptırımı(Security Enforcement)
Kod Örnekleri
Bildirime Dayalı(Declarative) Güvenlik Uygulaması
@Service
class MyApplicationService {
@Secured("ROLE_BUSINESS_PROCESSOR") //
public MyBusinessProcess performSomeStuff(MyBusinessProcess input) {
var customer = customerRepository.findById(input.getCustomerId()) //
.orElseThrow( () -> new CustomerNotFoundException(input.getCustomerId()));
var someResult = myDomainService.performABusinessOperation(customer); //
customer = customerRepository.save(customer);
return input.updateMyBusinessProcessWithResult(someResult); //
}
}
- Anatasyon, framework'e yalnızca ROLE_BUSINESS_PROCESSOR rolüne sahip kimliği doğrulanmış kullanıcıların yöntemi çağırmasına izin vermesi talimatını verir.
- Application servis(applicaiton service), domain modelindeki bir repodan bir domain model arar.
- Application servis, aggregate'i domain modelindeki bir domain servisine(domain service) geçirerek sonucu (ne olursa olsun) depolar.
- Application servisi, iş süreci nesnesini güncellemek için domain servisinin sonucunu kullanır ve aynı uzun süreli işleme katılan diğer applicaiton servis yöntemlerine aktarılabilmesi için onu döndürür.
Manuel Güvenlik Uygulaması
@Service
class MyApplicationService {
public MyBusinessProcess performSomeStuff(MyBusinessProcess input) {
// We assume SecurityContext is a thread-local class that contains information
// about the current user.
if (!SecurityContext.isLoggedOn()) { //
throw new AuthenticationException("No user logged on");
}
if (!SecurityContext.holdsRole("ROLE_BUSINESS_PROCESSOR")) { //
throw new AccessDeniedException("Insufficient privileges");
}
var customer = customerRepository.findById(input.getCustomerId())
.orElseThrow( () -> new CustomerNotFoundException(input.getCustomerId()));
var someResult = myDomainService.performABusinessOperation(customer);
customer = customerRepository.save(customer);
return input.updateMyBusinessProcessWithResult(someResult);
}
}
- Gerçek bir uygulamada, muhtemelen bir kullanıcı oturum açmamışsa istisnayı atan yardımcı yöntemler oluşturursunuz. Neyin kontrol edilmesi gerektiğini göstermek için bu örneğe yalnızca daha ayrıntılı bir versiyon ekledim.
- Önceki durumda olduğu gibi, yalnızca ROLE_BUSINESS_PROCESSOR rolüne sahip kullanıcıların yöntemi çağırmasına izin verilir.
Transaction yönetimi (Transaction Management)
Kod Örnekleri:
Bildirime Dayalı(Declarative) Transaction Yönetimi
@Service
class UserAdministrationService {
@Transactional //
public void resetPassword(UserId userId) {
var user = userRepository.findByUserId(userId); //
user.resetPassword(); //
userRepository.save(user);
}
}
- Framework, tüm yöntemin tek bir işlem içinde çalıştığından emin olacaktır. Bir istisna atılırsa, işlem geri alınır. Aksi takdirde, yöntem geri döndüğünde kesinleşir.
- Application servisi, Kullanıcı aggregate kökünü bulmak için domain modelinde bir repository çağırır.
- Application servisi, Kullanıcı aggreagte kökünde bir iş yöntemini çağırır.
Manuel Transaction Management
@Service
class UserAdministrationService {
@Transactional
public void resetPassword(UserId userId) {
var tx = transactionManager.begin(); //
try {
var user = userRepository.findByUserId(userId);
user.resetPassword();
userRepository.save(user);
tx.commit(); //
} catch (RuntimeException ex) {
tx.rollback(); //
throw ex;
}
}
}
- Tranaction yöneticisi application servisine enjekte edilmiştir, böylece servis yöntemi açıkça yeni bir işlem başlatabilir.
- Her şey çalışırsa, işlem parola sıfırlandıktan sonra gerçekleştirilir.
- Bir hata oluşursa, işlem geri alınır ve istisna yeniden oluşturulur.
Orkestrasyon
Kod Örnekleri
@Service
class CustomerRegistrationService {
@Transactional //
@PermitAll //
public Customer registerNewCustomer(CustomerRegistrationRequest request) {
var violations = validator.validate(request); //
if (violations.size() > 0) {
throw new InvalidCustomerRegistrationRequest(violations);
}
customerDuplicateLocator.checkForDuplicates(request); //
var customer = customerFactory.createNewCustomer(request); //
return customerRepository.save(customer); //
}
}
- Application servisti yöntemi bir transaction içinde çalışır.
- Application servisi yöntemine herhangi bir kullanıcı tarafından erişilebilir.
- Gelen kayıt talebinin gerekli tüm bilgileri içerip içermediğini kontrol etmek için bir JSR-303 doğrulayıcı çağırıyoruz. İstek geçersizse, kullanıcıya geri bildirilecek bir istisna atarız.
- Veritabanında aynı bilgilere sahip bir müşteri olup olmadığını kontrol edecek bir domain servisini çağırıyoruz. Durum böyleyse, domain servisi kullanıcıya geri yayılacak bir istisna (burada gösterilmemiştir) atar.
- Kayıt talebi nesnesinden gelen bilgilerle yeni bir Müşteri agrregate'i oluşturacak bir domain factory'si çağırıyoruz.
- Müşteriyi kaydetmek için bir domain repository'si çağırırız ve yeni oluşturulan ve kaydedilen müşteri aggregate root döndürürüz.
Domain Olay Dinleyicileri (Domain Event Listeners)
Giriş ve çıkış (Input and Output)
- Entityleri ve değer nesnelerini doğrudan domain modelinden kullanın.
- Ayrı Veri Aktarım Nesneleri (DTO'lar) kullanın.
- Yukarıdaki ikisinin birleşimi olan domain payload Nesnelerini (DPO'lar) kullanın.
Varlıklar ve Agregalar (Entities and Aggregates)
- Zaten sahip olduğunuz sınıfları kullanabilirsiniz
- Domian nesneleri ve DTO'lar arasında dönüştürme yapmaya gerek yoktur.
- Domain modelini doğrudan istemcilerle eşleştirir. Domain modeli değişirse, clientleriniz de değiştirmeniz gerekir.
- Kullanıcı girişini nasıl doğrulayacağınıza dair kısıtlamalar getirir (bununla ilgili daha sonra daha fazla bahsedeceğiz).
- Agregalarınız, clientin agregayı tutarsız bir duruma getiremeyeceği veya izin verilmeyen bir işlemi gerçekleştiremeyeceği şekilde tasarlamalısınız.
- Bir toplu (JPA) içindeki entitylerin geç yüklenmesiyle ilgili sorunlarla karşılaşabilirsiniz.
Veri Aktarım Nesneleri (Data Transfer Objects)
- İstemciler, domain modelinden ayrıştırılarak istemcileri değiştirmek zorunda kalmadan onu geliştirmeyi kolaylaştırır.
- Yalnızca gerçekte ihtiyaç duyulan veriler istemciler ve uygulama hizmetleri arasında aktarılır, bu da performansı artırır (özellikle istemci ve uygulama hizmeti dağıtılmış bir ortamda bir ağ üzerinden iletişim kuruyorsa).
- Domain modeline erişimi kontrol etmek, özellikle yalnızca belirli kullanıcıların belirli agrega yöntemlerini başlatmasına veya belirli toplu öznitelik değerlerini görüntülemesine izin veriliyorsa daha kolay hale gelir.
- Yalnızca applicaiton servisleri, aktif işlemlerin içindeki agregalarla etkileşime girecektir. Bu, bir agrega (JPA) içindeki entitylerin lazy yüklemesini kullanabileceğiniz anlamına gelir.
- DTO'lar arayüzlerse ve sınıflar değilse, daha da fazla esneklik elde edersiniz.
- Bakım için yeni bir DTO sınıfı seti alırsınız.
- Verileri DTO'lar ve agregalar arasında ileri geri taşımanız gerekir. DTO'lar ve entitiyler yapı olarak neredeyse benzer ise, bu özellikle sıkıcı olabilir. Bir takımda çalışıyorsanız, DTO'lar ve agregaların neden ayrılması gerektiğine dair iyi bir açıklamaya ihtiyacınız vardır.
Domain Yük Nesneleri(Domain Payload Objects)
- Her şey için DTO sınıfları oluşturmanıza gerek yoktur. Bir domain nesnesini doğrudan istemciye iletmek yeterince iyidir, bunu yaparsınız. Özel bir DTO'ya ihtiyacınız olduğunda, bir tane oluşturursunuz. İkisine de ihtiyacınız olduğunda ikisini de kullanırsınız.
- İlk alternatifle aynı. Dezavantajlar, yalnızca DPO'ların içine immutable değer nesnelerini dahil ederek hafifletilebilir.
Kod Örnekleri
public class CustomerListEntryDTO {
private CustomerId id;
private String name;
private LocalDate lastInvoiceDate;
Getters and setters omitted
}
@Service
public class CustomerListingService {
@Transactional
public List getCustomerList() {
var customers = customerRepository.findAll();
var dtos = new ArrayList();
for (var customer : customers) {
var lastInvoiceDate = invoiceService.findLastInvoiceDate(customer.getId());
dto = new CustomerListEntryDTO();
dto.setId(customer.getId());
dto.setName(customer.getName());
dto.setLastInvoiceDate(lastInvoiceDate);
dtos.add(dto);
}
return dto;
}
}
- Veri Aktarım Nesnesi, herhangi bir iş mantığı içermeyen bir veri yapısıdır. Bu özel DTO, yalnızca müşteri adını ve son fatura tarihini göstermesi gereken bir kullanıcı arabirimi liste görünümünde kullanılmak üzere tasarlanmıştır.
- Veritabanından tüm müşteri kümelerini arıyoruz. Gerçek dünyadaki bir uygulamada, bu yalnızca müşterilerin bir alt kümesini döndüren sayfalandırılmış bir sorgu olacaktır.
- Son fatura tarihi müşteri varlığında saklanmaz, bu nedenle bizim için aramak için bir domain servisini çağırmamız gerekir.
- DTO örneğini oluşturuyoruz ve onu verilerle dolduruyoruz.
public class CustomerInvoiceMonthlySummaryDPO { //
private Customer customer;
private YearMonth month;
private Collection invoices;
// Getters and setters omitted
}
@Service
public class CustomerInvoiceSummaryService {
public CustomerInvoiceMontlySummaryDPO getMonthlySummary(CustomerId customerId, YearMonth month) {
var customer = customerRepository.findById(customerId); //
var invoices = invoiceRepository.findByYearMonth(customerId, month); //
var dpo = new CustomerInvoiceMonthlySummaryDPO(); //
dpo.setCustomer(customer);
dpo.setMonth(month);
dpo.setInvoices(invoices);
return dpo;
}
}
- Domain Yük Nesnesi, hem domain nesnelerini (bu durumda entityler) hem de ek bilgileri (bu durumda yıl ve ay) içeren herhangi bir iş mantığı olmayan bir veri yapısıdır.
- Müşterinin agrega kökünü repodan alıyoruz.
- Müşterinin belirtilen yıl ve aya ait faturalarını alıyoruz.
- DPO örneğini oluşturup verilerle dolduruyoruz.
Giriş Doğrulama (Input Validation)
Biçim Doğrulaması (Format Validation)
İçerik Doğrulama (Content Validation)
Biçim Doğrulamalı Değer Nesnesi
public class PhoneNumber implements ValueObject {
private final String phoneNumber;
public PhoneNumber(String phoneNumber) {
Objects.requireNonNull(phoneNumber, "phoneNumber must not be null"); //
var sb = new StringBuilder();
char ch;
for (int i = 0; i lt phoneNumber.length(); ++i) {
ch = phoneNumber.charAt(i);
if (Character.isDigit(ch)) { //
sb.append(ch);
} else if (!Character.isWhitespace(ch) && ch != '(' && ch != ')' && ch != '-' && ch != '.') { //
throw new IllegalArgument(phoneNumber + " is not valid");
}
}
if (sb.length() == 0) { //
throw new IllegalArgumentException("phoneNumber must not be empty");
}
this.phoneNumber = sb.toString();
}
@Override
public String toString() {
return phoneNumber;
}
// Equals and hashCode omitted
}
Yerleşik Doğrulamalı Entity
public class Customer implements Entity {
// Fields omitted
public Customer(CustomerNo customerNo, String name, PostalAddress address) {
setCustomerNo(customerNo); //
setName(name);
setPostalAddress(address);
}
public setCustomerNo(CustomerNo customerNo) {
this.customerNo = Objects.requireNonNull(customerNo, "customerNo must not be null");
}
public setName(String name) {
Objects.requireNonNull(nanme, "name must not be null");
if (name.length() lt 1 || name.length > 50) { //
throw new IllegalArgumentException("Name must be between 1 and 50 characters");
}
this.name = name;
}
public setAddress(PostalAddress address) {
this.address = Objects.requireNonNull(address, "address must not be null");
}
}
- Setter yöntemlerinde uygulanan doğrulamayı gerçekleştirmek için kurucudan setterları çağırırız. Bir alt sınıfın bunlardan herhangi birini geçersiz kılmaya karar vermesi durumunda, bir kurucudan geçersiz kılınabilen yöntemleri çağırmanın küçük bir riski vardır. Bu durumda, setter yöntemlerini final olarak işaretlemek daha iyi olacaktır, ancak bazı kalıcılık çerçevelerinin bununla ilgili bir sorunu olabilir. Sadece ne yaptığınızı bilmeniz lazım.
- Burada bir stringin uzunluğunu kontrol ediyoruz. Her müşterinin bir adı olması gerektiğinden, alt sınır bir iş gereksinimidir. Bu durumda veritabanı, yalnızca 50 karakterlik stringleri depolamasına izin veren bir şemaya sahip olduğundan, üst düzey bir veritabanı gereksinimidir. Doğrulamayı buraya zaten ekleyerek, veritabanına çok uzun stringler eklemeye çalıştığınızda daha sonraki bir aşamada can sıkıcı SQL hatalarını önleyebilirsiniz.
JSR-303 Doğrulamalı Entity
public class Customer implements Entity {
@NotNull (1)
private CustomerNo customerNo;
@NotBlank (2)
@Size(max = 50) (3)
private String name;
@NotNull
private PostalAddress address;
// Setters omitted
}
- Bu anatasyon, entity kaydedildiğinde müşteri numarasının null olmamasını sağlar.
- Bu anatasyon, entity kaydedildiğinde adın boş veya null olmamasını sağlar.
- Bu anatasyon, entity kaydedildiğinde adın 50 karakterden uzun olmamasını sağlar.
Boyut Önemli mi?
Kod Örnekleri
public interface Command { //
}
public interface CommandHandler, R/> { //
R handleCommand(C command);
}
public class CommandGateway { //
// Fields omitted
public , R/> R handleCommand(C command) {
var handler = commandHandlers.findHandlerFor(command)
.orElseThrow(() -> new IllegalStateException("No command handler found"));
return handler.handleCommand(command);
}
}
public class CreateCustomerCommand implements Command { //
private final String name;
private final PostalAddress address;
private final PhoneNumber phone;
private final EmailAddress email;
// Constructor and getters omitted
}
public class CreateCustomerCommandHandler implements CommandHandler { //
@Override
@Transactional
public Customer handleCommand(CreateCustomerCommand command) {
var customer = new Customer();
customer.setName(command.getName());
customer.setAddress(command.getAddress());
customer.setPhone(command.getPhone());
customer.setEmail(command.getEmail());
return customerRepository.save(customer);
}
}
- Komut arayüzü, komutun sonucunu (çıktısını) da gösteren bir işaret arayüzüdür. Komutun çıkışı yoksa, sonuç Void olabilir.
- CommandHandler arabirimi, belirli bir komutu nasıl işleyeceğini (gerçekleştireceğini) ve sonucu nasıl döndüreceğini bilen bir sınıf tarafından uygulanır.
- İstemciler, tek tek komut işleyicileri aramak zorunda kalmamak için bir CommandGateway ile etkileşime girer. Ağ geçidi, mevcut tüm komut işleyicileri ve herhangi bir komuta göre doğru olanı nasıl bulacağını bilir. İşleyicileri aramak için kullanılan kod, işleyicileri kaydetmeye yönelik temel mekanizmaya bağlı olduğundan, örnekte dahil edilmemiştir.
- Her komut, Komut arayüzünü uygular ve komutu gerçekleştirmek için gerekli tüm bilgileri içerir. Komutlarımı yerleşik doğrulama ile değişmez hale getirmeyi seviyorum, ancak bunları değiştirilebilir ve JSR-303 doğrulamasını da kullanabilirsiniz. Hatta komutlarınızı arayüz olarak bırakabilir ve maksimum esneklik için istemcilerin kendilerinin uygulamasına izin verebilirsiniz.
- Her komutun, komutu gerçekleştiren ve sonucu döndüren kendi işleyicisi vardır.
Bağlantı Noktaları ve Adaptörler (Ports And Adaptors)
Port Nedir?
- Bir veritabanına erişmek için uygulamanız tarafından kullanılan bir port
- Uygulamanız tarafından e-posta veya kısa mesaj gibi mesajlar göndermek için kullanılan bir port
- İnsan kullanıcılar tarafından uygulamanıza erişmek için kullanılan bir port
- Uygulamanıza erişmek için diğer sistemler tarafından kullanılan bir port
- Uygulamanıza erişmek için belirli bir kullanıcı grubu tarafından kullanılan bir port
- Belirli bir kullanım durumunu ortaya çıkaran bir port
- İstemcileri sorgulamak için tasarlanmış bir port
- Müşterilere abone olmak için tasarlanmış bir port
- Senkronize iletişim için tasarlanmış bir port
- Eşzamansız iletişim için tasarlanmış bir port
- Belirli bir cihaz türü için tasarlanmış bir port
Adaptör nedir?
Koddaki Portlar ve Adaptörler
Örnek 1: REST API
- Input olarak ham XML / JSON veya serileştirilmemiş POJO'ları alın,
- Application servislerini çağırın,
- Frameork tarafından serileştirilecek ham XML / JSON veya POJO olarak bir yanıt oluşturun ve
- Response'u client'e iletin.