Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Just the facts

Just the facts

Presented to Working Software in Milan on 30 June 2023

Sara Pellegrini presented an interesting application design problem in her Kill Aggregate series of articles, where she argued that some of the accepted conventions in the event sourcing design style are not good enough to solve it. In this presentation we review some of the fundamentals patterns that are part of the context of Sara's argument, and present a simple alternative way to look at it

Matteo Vaccari

July 06, 2023
Tweet

More Decks by Matteo Vaccari

Other Decks in Programming

Transcript

  1. © 2023 Thoughtworks | Confidential Business rule #1 “A student

    may enroll in a maximum of 10 courses” 2
  2. © 2023 Thoughtworks | Confidential 3 @RestController public class CourseEnrollmentController

    { @PostMapping("/student/courses") public void enrollStudentInCourse(StudentCourseEnrollmentRequest request) { long enrollments = findCountOfEnrollments(request.studentId()); if (enrollments == MAX_ENROLLMENTS) { throw new EnrollmentLimitExceeded(); } insertNewEnrollment(request.studentId(), request.courseId()); } } This is how the problem could be solved with today’s technology… and the style of the 60’s
  3. © 2023 Thoughtworks | Confidential 4 @RestController public class CourseEnrollmentController

    { @PostMapping("/student/courses") public void enrollStudentInCourse(StudentCourseEnrollmentRequest request) { long enrollments = findCountOfEnrollments(request.studentId()); if (enrollments == MAX_ENROLLMENTS) { throw new EnrollmentLimitExceeded(); } insertNewEnrollment(request.studentId(), request.courseId()); } private long findCountOfEnrollments(long studentId) { String query = """ select count(*) from student_course_enrollments where student_id = ? and status = ‘active’ """; return jdbcTemplate.queryForObject(query, (rs, __) -> rs.getLong(1), studentId); } private static class EnrollmentLimitExceeded extends RuntimeException {} } This is how the problem could be solved with today’s technology… and the style of the 60’s
  4. © 2023 Thoughtworks | Confidential 5 @RestController public class CourseEnrollmentController

    { @PostMapping("/student/courses") public void enrollStudentInCourse(StudentCourseEnrollmentRequest request) { long enrollments = findCountOfEnrollments(request.studentId()); if (enrollments == MAX_ENROLLMENTS) { throw new EnrollmentLimitExceeded(); } insertNewEnrollment(request.studentId(), request.courseId()); } private long findCountOfEnrollments(long studentId) { String query = """ select count(*) from student_course_enrollments where student_id = ? and status = ‘active’ """; return jdbcTemplate.queryForObject(query, (rs, __) -> rs.getLong(1), studentId); } private void insertNewEnrollment(long studentId, long courseId) { String updateQuery = "insert into student_course_enrollments (student_id, course_id) VALUES (?, ?)"; jdbcTemplate.update(updateQuery, studentId, courseId); } private static class EnrollmentLimitExceeded extends RuntimeException {} } This is how the problem could be solved with today’s technology… and the style of the 60’s
  5. © 2023 Thoughtworks | Confidential “Transaction Script” A Transaction Script

    organizes all this logic primarily as a single procedure, making calls directly to the database or through a thin database wrapper. Each transaction will have its own Transaction Script 6 https://martinfowler.com/eaaCatalog/transactionScript.html
  6. © 2023 Thoughtworks | Confidential Business rule #2 “Students may

    change their city of residence” (“... and the new city must be a different city from the old one”) 7
  7. © 2023 Thoughtworks | Confidential 8 @RestController public class ChangeCityOfResidenceController

    { private final JdbcTemplate jdbcTemplate; public ChangeCityOfResidenceController(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @PutMapping("/student/city-of-residence") public void changeCityOfResidence(StudentChangeCityOfResidenceRequest request) { City previousCity = jdbcTemplate.queryForObject( "select city_of_residence_id from students where id = ?", (rs, rowNum) -> City.of(rs.getInt("city_of_residence_id")), request.studentId() ); if (previousCity.equals(request.cityId())) { throw new CityOfResidenceMustBeChangedException(); } String updateQuery = "update students set city_of_residence_id = ?"; jdbcTemplate.update(updateQuery, request.studentId(), request.cityId()); } private static class CityOfResidenceMustBeChangedException extends RuntimeException {} } Another business operation, another transaction script.
  8. © 2023 Thoughtworks | Confidential “Domain Model” An object model

    of the domain that incorporates both behavior and data. … business logic can be very complex. Rules and logic describe many different cases and slants of behavior, and it's this complexity that objects were designed to work with. 10 https://martinfowler.com/eaaCatalog/domainModel.html
  9. © 2023 Thoughtworks | Confidential 11 public class Student {

    private static final int MAX_COURSE_ENROLLMENTS = 10; private List<Course> enrolledCourses; private City cityOfResidence; public Student(List<Course> courses, City cityOfResidence) { this.enrolledCourses = courses; this.cityOfResidence = cityOfResidence; } public void enrollInCourse(Course course) throws EnrollmentLimitExceeded { if (enrolledCourses.size() < MAX_COURSE_ENROLLMENTS) { enrolledCourses.add(course); } else { throw new EnrollmentLimitExceeded(); } } public void changeCityOfResidence(City newCityOfResidence) throws CityOfResidenceMustBeChanged { if (cityOfResidence.equals(newCityOfResidence)) { throw new CityOfResidenceMustBeChanged(); } this.cityOfResidence = newCityOfResidence; } private static class EnrollmentLimitExceeded extends DomainException {} private static class CityOfResidenceMustBeChanged extends DomainException {} } The domain model holds all relevant data to implement business rules, with none of the persistence
  10. © 2023 Thoughtworks | Confidential 13 public class Student {

    private static final int MAX_COURSE_ENROLLMENTS = 10; private List<Course> enrolledCourses; private City cityOfResidence; public Student(List<Course> courses, City cityOfResidence) { this.enrolledCourses = courses; this.cityOfResidence = cityOfResidence; } public void enrollInCourse(Course course) throws EnrollmentLimitExceeded { if (enrolledCourses.size() < MAX_COURSE_ENROLLMENTS) { enrolledCourses.add(course); } else { throw new EnrollmentLimitExceeded(); } } public void changeCityOfResidence(City newCityOfResidence) throws CityOfResidenceMustBeChanged { if (cityOfResidence.equals(newCityOfResidence)) { throw new CityOfResidenceMustBeChanged(); } this.cityOfResidence = newCityOfResidence; } private static class EnrollmentLimitExceeded extends DomainException {} private static class CityOfResidenceMustBeChanged extends DomainException {} } What do these two operations have in common?
  11. © 2023 Thoughtworks | Confidential Domain modelling is an iterative

    process 14 Student Student_Personal_Info Enrollment has many city_of_residence There’s no interaction between enrollments and city of residence So we model two aggregates
  12. © 2023 Thoughtworks | Confidential 15 Business rule #3 “Students

    who are resident in Rome can enroll in “Roman history” and this does not count against the limit of max 10 enrollments”
  13. © 2023 Thoughtworks | Confidential 16 @RestController public class CourseEnrollmentController

    { @PostMapping("/student/courses") public void enrollStudentInCourse(StudentCourseEnrollmentRequest request) { long enrollments = findCountOfEnrollments(request.studentId()); if (enrollments == MAX_ENROLLMENTS) { throw new EnrollmentLimitExceeded(); } insertNewEnrollment(request.studentId(), request.courseId()); } } TRANSACTION SCRIPT
  14. © 2023 Thoughtworks | Confidential 17 @RestController public class CourseEnrollmentController

    { @PostMapping("/student/courses") public void enrollStudentInCourse(StudentCourseEnrollmentRequest request) { long enrollments = findCountOfEnrollments(request.studentId()); if (enrollments == MAX_ENROLLMENTS) { throw new EnrollmentLimitExceeded(); } insertNewEnrollment(request.studentId(), request.courseId()); } } TRANSACTION SCRIPT
  15. © 2023 Thoughtworks | Confidential 18 @RestController public class CourseEnrollmentController

    { @PostMapping("/student/courses") public void enrollStudentInCourse(StudentCourseEnrollmentRequest request) { long enrollments = findCountOfEnrollments(request.studentId()); int effectiveMaxEnrollments = MAX_ENROLLMENTS; if (enrollments == effectiveMaxEnrollments) { throw new EnrollmentLimitExceeded(); } insertNewEnrollment(request.studentId(), request.courseId()); } } TRANSACTION SCRIPT
  16. © 2023 Thoughtworks | Confidential 19 @RestController public class CourseEnrollmentController

    { @PostMapping("/student/courses") public void enrollStudentInCourse(StudentCourseEnrollmentRequest request) { long enrollments = findCountOfEnrollments(request.studentId()); int effectiveMaxEnrollments = MAX_ENROLLMENTS; if (enrollments == effectiveMaxEnrollments) { throw new EnrollmentLimitExceeded(); } insertNewEnrollment(request.studentId(), request.courseId()); } } TRANSACTION SCRIPT
  17. © 2023 Thoughtworks | Confidential 20 @RestController public class CourseEnrollmentController

    { @PostMapping("/student/courses") public void enrollStudentInCourse(StudentCourseEnrollmentRequest request) { long enrollments = findCountOfEnrollments(request.studentId()); int effectiveMaxEnrollments = MAX_ENROLLMENTS; if (cityOfResidence(request.studentId()) == ROME && request.courseId() == ROMAN_HISTORY) { effectiveMaxEnrollments = MAX_ENROLLMENTS + 1; } if (enrollments == effectiveMaxEnrollments) { throw new EnrollmentLimitExceeded(); } insertNewEnrollment(request.studentId(), request.courseId()); } } TRANSACTION SCRIPT
  18. © 2023 Thoughtworks | Confidential 22 Business rule #3, done!

    👍 Or…. did we miss something? 🤨
  19. © 2023 Thoughtworks | Confidential Failure scenario 23 given a

    student who is resident in Rome and is enrolled in 11 courses, including “Roman history” when the student changes residence to Milan then ???
  20. © 2023 Thoughtworks | Confidential Failure scenario 24 given a

    student who is resident in Rome and is enrolled in 11 courses, including “Roman history” when the student changes residence to Milan then ??? A conversation with domain experts should clarify what should happen. Some possibilities: • The change should fail ⟶ the existing rules are enforced, OR • The change is permitted ⟶ this is a new business rule, OR • We no longer think of changing residence as an instantaneous transaction, it becomes a process ⟶ we improve our model of the business process
  21. © 2023 Thoughtworks | Confidential 26 public class Student {

    private List<Course> enrolledCourses; private City cityOfResidence; public void enrollInCourse(Course course) throws EnrollmentLimitExceeded { if (enrolledCourses.size() < MAX_COURSE_ENROLLMENTS { enrolledCourses.add(course); } else { throw new EnrollmentLimitExceeded(); } } public void changeCityOfResidence(City newCityOfResidence) { this.cityOfResidence = newCityOfResidence; } private static class EnrollmentLimitExceeded extends DomainException {} } DOMAIN MODEL
  22. © 2023 Thoughtworks | Confidential 27 public class Student {

    private List<Course> enrolledCourses; private City cityOfResidence; public void enrollInCourse(Course course) throws EnrollmentLimitExceeded { if (enrolledCourses.size() < MAX_COURSE_ENROLLMENTS) { enrolledCourses.add(course); } else { throw new EnrollmentLimitExceeded(); } } public void changeCityOfResidence(City newCityOfResidence) { this.cityOfResidence = newCityOfResidence; } private static class EnrollmentLimitExceeded extends DomainException { } } DOMAIN MODEL
  23. © 2023 Thoughtworks | Confidential 28 public class Student {

    private List<Course> enrolledCourses; private City cityOfResidence; public void enrollInCourse(Course course) throws EnrollmentLimitExceeded { if (enrolledCourses.size() < MAX_COURSE_ENROLLMENTS || cityOfResidence.equals(ROME) && course.equals(ROMAN_HISTORY)) { enrolledCourses.add(course); } else { throw new EnrollmentLimitExceeded(); } } public void changeCityOfResidence(City newCityOfResidence) { this.cityOfResidence = newCityOfResidence; } private static class EnrollmentLimitExceeded extends DomainException { } } DOMAIN MODEL
  24. © 2023 Thoughtworks | Confidential 29 public class Student {

    private List<Course> enrolledCourses; private City cityOfResidence; public void enrollInCourse(Course course) throws EnrollmentLimitExceeded { if (enrolledCourses.size() < MAX_COURSE_ENROLLMENTS || cityOfResidence.equals(ROME) && course.equals(ROMAN_HISTORY)) { enrolledCourses.add(course); } else { throw new EnrollmentLimitExceeded(); } } public void changeCityOfResidence(City newCityOfResidence) { this.cityOfResidence = newCityOfResidence; } private static class EnrollmentLimitExceeded extends DomainException { } } DOMAIN MODEL
  25. © 2023 Thoughtworks | Confidential 30 public class Student {

    private List<Course> enrolledCourses; private City cityOfResidence; public void enrollInCourse(Course course) throws EnrollmentLimitExceeded { if (enrolledCourses.size() < MAX_COURSE_ENROLLMENTS || cityOfResidence.equals(ROME) && course.equals(ROMAN_HISTORY)) { enrolledCourses.add(course); } else { throw new EnrollmentLimitExceeded(); } } public void changeCityOfResidence(City newCityOfResidence) throws EnrollmentLimitExceeded { if (enrolledCourses.size() > MAX_COURSE_ENROLLMENTS) { throw new EnrollmentLimitExceeded(); } this.cityOfResidence = newCityOfResidence; } private static class EnrollmentLimitExceeded extends DomainException { } } DOMAIN MODEL
  26. © 2023 Thoughtworks | Confidential 31 public class Student {

    private List<Course> enrolledCourses; private City cityOfResidence; public void enrollInCourse(Course course) throws EnrollmentLimitExceeded { if (enrolledCourses.size() < MAX_COURSE_ENROLLMENTS || cityOfResidence.equals(ROME) && course.equals(ROMAN_HISTORY)) { enrolledCourses.add(course); } else { throw new EnrollmentLimitExceeded(); } } public void changeCityOfResidence(City newCityOfResidence) throws EnrollmentLimitExceeded { if (enrolledCourses.size() > MAX_COURSE_ENROLLMENTS) { throw new EnrollmentLimitExceeded(); } this.cityOfResidence = newCityOfResidence; } private static class EnrollmentLimitExceeded extends DomainException { } } This is why aggregates are useful: • a place where invariants are maintained • all relevant information at hand • the simplest code • no distractions from FX
  27. © 2023 Thoughtworks | Confidential 32 Transaction Script vs. Domain

    Model A transaction script is appropriate when • business logic is simple, • and changes infrequently In all other cases, a domain model is likely more appropriate
  28. © 2023 Thoughtworks | Confidential 34 Business rule #4 “A

    course may have a maximum of 100 enrolled students”
  29. © 2023 Thoughtworks | Confidential 35 Business rule #4 “A

    course may have a maximum of 100 enrolled students” 🤔 Now enrollment may fail for another reason! Course Student Enrollment has many has many Which is the aggregate root here? The Course? Or the Student? Max 100 enrollments Max 10 enrollments Shouldn’t aggregates be small?
  30. © 2023 Thoughtworks | Confidential Domain service Sometimes, it just

    isn’t a thing. . . . When a significant process or transformation in the domain is not a natural responsibility of an ENTITY or VALUE OBJECT, add an operation to the model as a standalone interface declared as a SERVICE 36
  31. © 2023 Thoughtworks | Confidential Domain service vs Application service

    37 Watch out: a domain service is not an application service! Domain service: - stateless - no fx - yes domain logic Application service: - stateless - yes fx - no domain logic
  32. © 2023 Thoughtworks | Confidential All together 38 Enrollment Course

    Student Web controller Application service Queue listener Domain service Repository 1 2 3 4 5 Recreates from DB The domain service is now the entry point to the domain. In this case, it coordinates updates on two aggregates, ensuring all business rules are enforced FX NOFX
  33. © 2023 Thoughtworks | Confidential Repository A Repository mediates between

    the domain and data mapping layers, acting like an in-memory domain object collection 40 https://martinfowler.com/eaaCatalog/repository.html
  34. © 2023 Thoughtworks | Confidential The repository “rehydrates” the domain

    model 41 Enrollment Student Repository Recreates from DB We delegate all persistence to the repository. But who is doing the coordination between the two? FX NOFX has many Invokes the aggregate root
  35. © 2023 Thoughtworks | Confidential “Service Layer” A Service Layer

    defines an application's boundary and its set of available operations from the perspective of interfacing client layers. It encapsulates the application's business logic, controlling transactions and coordinating responses in the implementation of its operations. 42 https://martinfowler.com/eaaCatalog/serviceLayer.html
  36. © 2023 Thoughtworks | Confidential Execute business operation The application

    service coordinates persistence and domain logic 43 Enrollment Student Application service Repository 1 2 3 Recreate from DB FX NOFX has many Find the aggregate 4 Save the results
  37. © 2023 Thoughtworks | Confidential 44 public class StudentEnrollmentApplicationService {

    private StudentRepository studentRepository; public StudentEnrollmentApplicationService(StudentRepository studentRepository) { this.studentRepository = studentRepository; } public void enrollStudentInCourse(StudentCourseEnrollmentRequest request) { Student student = studentRepository.findById(request.studentId()); student.enrollInCourse(request.courseId()); studentRepository.save(student); } } FX FX NOFX
  38. © 2023 Thoughtworks | Confidential 45 public class StudentEnrollmentApplicationService {

    private StudentRepository studentRepository; public StudentEnrollmentApplicationService(StudentRepository studentRepository) { this.studentRepository = studentRepository; } public void enrollStudentInCourse(StudentCourseEnrollmentRequest request) { Student student = studentRepository.findById(request.studentId()); student.enrollInCourse(request.courseId()); studentRepository.save(student); } } FX FX NOFX Implementing “save” can be complicated “save” detects changes and persists them
  39. © 2023 Thoughtworks | Confidential Object-relational mapping to the rescue

    (?) ORMs make it “easy” to persist complex domain models. BUT! • MAGIC • Tight coupling of data and domain model 46
  40. © 2023 Thoughtworks | Confidential Magic Is Bad Object-relational mapping

    to the rescue (?) ORMs make it “easy” to persist complex domain models. BUT! • Tight coupling of data and domain model • MAGIC 47
  41. © 2023 Thoughtworks | Confidential Now we have two models

    48 Student Enrollment has many Student Enrollment has many data model domain model
  42. © 2023 Thoughtworks | Confidential Now we have two models

    49 Student Enrollment has many Student Enrollment has many data model domain model Spot the differences!
  43. © 2023 Thoughtworks | Confidential Now we have two models

    50 Student Enrollment has many Student Enrollment has many data model domain model The 80’s style of persistence: 1. The DB has the latest version of the state of the domain model 2. The structure of the data and domain model are the same 3. If you’re interested in modification history, it requires (a lot of) extra work 4. If you’re using ORM, your DB is tightly coupled to your domain model 5. If you don’t use ORM, it’s a lot of work to a. detect and save changes b. protect against concurrent changes
  44. © 2023 Thoughtworks | Confidential Enter Event Sourcing Greg Young

    introduced Event Sourcing circa 2005 as a radically different way to implement business applications. However. • It requires a specialized event store • It is non-trivial to make it work in practice • And this is why it’s best to use a framework 51
  45. © 2023 Thoughtworks | Confidential “Just the facts” Taking inspiration

    from Event Sourcing • Save in the DB the commands that were received… ◦ … and the outcome (accepted or rejected) • When a new command arrives: ◦ you apply all the previous commands to the aggregate ◦ the aggregate decides whether to accept or reject the command ◦ save the command and the response we sent back • It’s a bit like event sourcing, but ◦ You save commands not (just) events ◦ You don’t need a specialized data store, use a standard DB ◦ You don’t need to “buy” the event sourcing architecture 52
  46. © 2023 Thoughtworks | Confidential “Just the facts” The commands

    (and events) you receive are “facts” You don’t need anything else but the facts to reconstruct the truth of your system This style may not be applicable everywhere, but it works well where it can 53
  47. © 2023 Thoughtworks | Confidential This pattern was used successfully

    1. A system to track tire lifetime through an embedded sensor 2. A system to quote car insurance policies 54
  48. © 2023 Thoughtworks | Confidential The two aspects of applications

    55 1. Behaviours: what the application does 2. Data: the information that must be persisted These two forces are in opposition The behaviours • Are needed to make the application useful • Change frequently with business changes The data • Are needed to support the behaviours • Are needed to support the business • May outlive the application
  49. © 2023 Thoughtworks | Confidential Behaviours are implemented in application

    code → domain model Data are implemented in databases → data model The domain model and data model can be (much) different The two models serve different purposes 56
  50. © 2023 Thoughtworks | Confidential “Just the facts” • Save

    in the DB the commands that were received… ◦ … and the outcome (accepted or rejected) • When a new command arrives: ◦ you apply all the previous commands to the aggregate ◦ the aggregate decides whether to accept or reject the command ◦ save the command and the response we sent back • It’s a bit like event sourcing, but ◦ You save commands not events ◦ You don’t need a specialized data store, use a standard DB ◦ You don’t need to “buy” the event sourcing architecture 57
  51. © 2023 Thoughtworks | Confidential 59 This book, published in

    2002, was extremely influential. DHH, the author of Ruby on Rails, explicitly mentions it as an inspiration. And many, many frameworks that came after RoR were influenced by it. The book consists of 2 parts; the first part is narrative, and the second part is a catalog of patterns. https://martinfowler.com/books/eaa.html
  52. © 2023 Thoughtworks | Confidential 60 Straight from DHH: The

    key tome to study for how to break down large problem spaces into beautiful domain models is Eric Evan's Domain-Driven Design. But you should only graduate to that level of strategic, architectural aspirations after you've mastered the basics of tactical programming through books like Kent Beck's Smalltalk Best Practices (if you work in object-orientated languages) and Martin Fowler's Patterns of Enterprise Application Architecture.
  53. © 2023 Thoughtworks | Confidential 61 Thanks to Sara Pellegrini

    for her insightful blog series on Kill Aggregate that inspired this presentation!