Final Project
Building the Domain Model
আপনি একটি free preview lesson দেখছেন।
Lesson Overview
Final Project-এর architecture design করার পর এবার আমরা domain layer implement করব।
এই lesson-এ কোনো file storage থাকবে না।
কোনো console menu থাকবে না।
কোনো repository implementationও থাকবে না।
Focus থাকবে শুধু:
Domain correctness
আমরা তৈরি করব:
CourseCodeLessonIdLessonCourseStatusCourseCourseSummaryLearnerIdEmailAddressLearnerEnrollmentIdEnrollmentStatusEnrollmentEnrollmentSummary
এই typesগুলো application-এর core language তৈরি করবে।
Domain Layer Goals
আমাদের domain model এমন হতে হবে যাতে:
Invalid IDs create করা না যায়
Blank title create করা না যায়
Negative price create করা না যায়
Course নিজের lessons protect করে
Invalid publication prevent হয়
Enrollment invalid transition prevent করে
Mutable state বাইরে leak না হয়
Value objects stable equality provide করে
Strong domain model-এর principle:
Invalid state যতটা সম্ভব construction-এর সময়ই reject করুন।
Part 1: CourseCode
CourseCode একটি immutable value object।
Requirements:
- Cannot be null
- Cannot be blank
- Normalize whitespace
- Normalize uppercase
- Allow only:
A-Z
0-9
-
CourseCode.java
package io.liveklass.course;
import java.util.Locale;
public record CourseCode(
String value
) {
public CourseCode {
if (
value == null
|| value.isBlank()
) {
throw new IllegalArgumentException(
"Course code is required."
);
}
value =
value.strip()
.toUpperCase(
Locale.ROOT
);
if (
!value.matches(
"[A-Z0-9-]+"
)
) {
throw new IllegalArgumentException(
"Course code contains unsupported characters."
);
}
}
@Override
public String toString() {
return value;
}
}
Why Locale.ROOT?
Case conversion can theoretically behave differently under different locales।
For technical identifiers such as:
JAVA-OOP
BACKEND
SYSTEM-DESIGN
we want locale-independent normalization।
Therefore:
toUpperCase(
Locale.ROOT
)
is a good choice।
Equality Comes Automatically
Because CourseCode is a record:
new CourseCode(
"java-oop"
)
equals:
new CourseCode(
" JAVA-OOP "
)
after normalization।
Generated:
equals()
hashCode()
use the normalized component।
Part 2: LessonId
Lesson ID must be positive।
LessonId.java
package io.liveklass.course;
public record LessonId(
long value
) {
public LessonId {
if (value <= 0) {
throw new IllegalArgumentException(
"Lesson id must be positive."
);
}
}
@Override
public String toString() {
return Long.toString(
value
);
}
}
Why Not Use Raw long Everywhere?
Compare:
addLesson(
4L,
...
);
with:
addLesson(
new LessonId(
4L
),
...
);
The second communicates:
This number represents a lesson identifier.
It also guarantees:
value > 0
once constructed।
Part 3: Lesson
A lesson is immutable in this project।
It contains:
LessonId
title
content
Once created, lesson data will not change।
Lesson.java
package io.liveklass.course;
public record Lesson(
LessonId id,
String title,
String content
) {
public Lesson {
if (id == null) {
throw new IllegalArgumentException(
"Lesson id is required."
);
}
if (
title == null
|| title.isBlank()
) {
throw new IllegalArgumentException(
"Lesson title is required."
);
}
if (
content == null
|| content.isBlank()
) {
throw new IllegalArgumentException(
"Lesson content is required."
);
}
title =
title.strip();
content =
content.strip();
}
}
Why Is Lesson a Record?
Because in this project it represents:
A complete immutable value
There is no:
lesson.setTitle(...)
lesson.setContent(...)
If lesson editing were required later, we would revisit the design rather than automatically adding setters।
Part 4: CourseStatus
Course lifecycle:
DRAFT
→
PUBLISHED
→
ARCHIVED
CourseStatus.java
package io.liveklass.course;
public enum CourseStatus {
DRAFT,
PUBLISHED,
ARCHIVED
}
Why Enum Instead of Booleans?
Weak:
boolean published;
boolean archived;
This can create impossible state:
published = false
archived = true
or:
published = true
archived = true
without clearly defined semantics।
Enum gives exactly one state at a time:
CourseStatus status;
Part 5: CourseSummary
Before implementing Course, define its immutable read model।
CourseSummary.java
package io.liveklass.course;
public record CourseSummary(
CourseCode code,
String title,
long priceInPaisa,
CourseStatus status,
int lessonCount
) {
public CourseSummary {
if (code == null) {
throw new IllegalArgumentException(
"Course code is required."
);
}
if (
title == null
|| title.isBlank()
) {
throw new IllegalArgumentException(
"Course title is required."
);
}
if (priceInPaisa < 0) {
throw new IllegalArgumentException(
"Course price cannot be negative."
);
}
if (status == null) {
throw new IllegalArgumentException(
"Course status is required."
);
}
if (lessonCount < 0) {
throw new IllegalArgumentException(
"Lesson count cannot be negative."
);
}
title =
title.strip();
}
}
Why Summary Instead of Returning Everything?
A course-list screen might only need:
Code
Title
Price
Status
Lesson count
It does not need mutable domain internals।
Read models reduce accidental coupling।
Part 6: Course
Course is a mutable entity with controlled behavior।
It owns:
CourseCode
Title
Price
Status
Lessons
Rules:
- Starts
DRAFT - Cannot have duplicate lesson IDs
- Lessons can only be added in
DRAFT - Must have at least one lesson before publish
- Only
DRAFTcan publish - Only
PUBLISHEDcan archive - Published or archived course title cannot change
Course.java
package io.liveklass.course;
import java.util.ArrayList;
import java.util.List;
public final class Course {
private final CourseCode code;
private final long priceInPaisa;
private final List<Lesson> lessons;
private String title;
private CourseStatus status;
public Course(
CourseCode code,
String title,
long priceInPaisa
) {
this(
code,
title,
priceInPaisa,
CourseStatus.DRAFT,
List.of()
);
}
private Course(
CourseCode code,
String title,
long priceInPaisa,
CourseStatus status,
List<Lesson> lessons
) {
if (code == null) {
throw new IllegalArgumentException(
"Course code is required."
);
}
if (
title == null
|| title.isBlank()
) {
throw new IllegalArgumentException(
"Course title is required."
);
}
if (priceInPaisa < 0) {
throw new IllegalArgumentException(
"Course price cannot be negative."
);
}
if (status == null) {
throw new IllegalArgumentException(
"Course status is required."
);
}
if (lessons == null) {
throw new IllegalArgumentException(
"Lessons are required."
);
}
this.code =
code;
this.title =
title.strip();
this.priceInPaisa =
priceInPaisa;
this.status =
status;
this.lessons =
new ArrayList<>(
lessons
);
validateLessonIds();
validateRestoredState();
}
public static Course restore(
CourseCode code,
String title,
long priceInPaisa,
CourseStatus status,
List<Lesson> lessons
) {
return new Course(
code,
title,
priceInPaisa,
status,
lessons
);
}
public void changeTitle(
String newTitle
) {
if (
newTitle == null
|| newTitle.isBlank()
) {
throw new IllegalArgumentException(
"Course title is required."
);
}
if (
status
!= CourseStatus.DRAFT
) {
throw new IllegalStateException(
"Only draft course title can be changed."
);
}
title =
newTitle.strip();
}
public void addLesson(
Lesson lesson
) {
if (lesson == null) {
throw new IllegalArgumentException(
"Lesson is required."
);
}
if (
status
!= CourseStatus.DRAFT
) {
throw new IllegalStateException(
"Lessons can only be added to draft courses."
);
}
if (
containsLesson(
lesson.id()
)
) {
throw new IllegalArgumentException(
"Lesson id already exists: "
+ lesson.id()
+ "."
);
}
lessons.add(
lesson
);
}
public void publish() {
if (
status
!= CourseStatus.DRAFT
) {
throw new IllegalStateException(
"Only draft courses can be published."
);
}
if (lessons.isEmpty()) {
throw new IllegalStateException(
"Course must have at least one lesson before publication."
);
}
status =
CourseStatus.PUBLISHED;
}
public void archive() {
if (
status
!= CourseStatus.PUBLISHED
) {
throw new IllegalStateException(
"Only published courses can be archived."
);
}
status =
CourseStatus.ARCHIVED;
}
public boolean isPublished() {
return status
== CourseStatus.PUBLISHED;
}
public boolean isArchived() {
return status
== CourseStatus.ARCHIVED;
}
public boolean containsLesson(
LessonId lessonId
) {
if (lessonId == null) {
throw new IllegalArgumentException(
"Lesson id is required."
);
}
return lessons.stream()
.anyMatch(
lesson ->
lesson.id()
.equals(
lessonId
)
);
}
public CourseSummary summary() {
return new CourseSummary(
code,
title,
priceInPaisa,
status,
lessons.size()
);
}
public CourseCode getCode() {
return code;
}
public String getTitle() {
return title;
}
public long getPriceInPaisa() {
return priceInPaisa;
}
public CourseStatus getStatus() {
return status;
}
public List<Lesson> getLessons() {
return List.copyOf(
lessons
);
}
private void validateLessonIds() {
long uniqueIds =
lessons.stream()
.map(
Lesson::id
)
.distinct()
.count();
if (
uniqueIds
!= lessons.size()
) {
throw new IllegalArgumentException(
"Course contains duplicate lesson ids."
);
}
}
private void validateRestoredState() {
if (
status
== CourseStatus.PUBLISHED
&& lessons.isEmpty()
) {
throw new IllegalArgumentException(
"Published course must contain at least one lesson."
);
}
}
}
Why restore() Exists
A new course always starts:
DRAFT
But repository may load:
PUBLISHED
or:
ARCHIVED
We should not do this during loading:
Course course =
new Course(...);
course.addLesson(...);
course.publish();
course.archive();
just to reconstruct stored state।
That would replay business transitions rather than restore persisted state।
Instead:
Course.restore(...)
makes the intent explicit।
Restore Must Still Protect Invariants
A restore method is not:
Bypass all validation
This should still fail:
Course.restore(
code,
"Java",
1000L,
CourseStatus.PUBLISHED,
List.of()
);
because:
Published course must have lessons.
Persisted corruption should not create an invalid domain object।
Defensive Collection Ownership
Constructor receives:
List<Lesson> lessons
but stores:
new ArrayList<>(
lessons
)
So caller cannot later modify Course by changing its input list।
Output:
List.copyOf(
lessons
)
prevents callers from mutating the internal list structure।
Part 7: LearnerId
LearnerId.java
package io.liveklass.learner;
public record LearnerId(
long value
) {
public LearnerId {
if (value <= 0) {
throw new IllegalArgumentException(
"Learner id must be positive."
);
}
}
@Override
public String toString() {
return Long.toString(
value
);
}
}
Part 8: EmailAddress
Email validation can become extremely complex।
For this learning project, we deliberately use a limited application rule:
Non-blank
No spaces
Exactly some content before @
Some content after @
A dot exists after @
This is not intended to implement the full email RFC।
EmailAddress.java
package io.liveklass.learner;
import java.util.Locale;
public record EmailAddress(
String value
) {
public EmailAddress {
if (
value == null
|| value.isBlank()
) {
throw new IllegalArgumentException(
"Email address is required."
);
}
value =
value.strip()
.toLowerCase(
Locale.ROOT
);
if (
value.contains(
" "
)
|| !value.matches(
"^[^@]+@[^@]+\\.[^@]+$"
)
) {
throw new IllegalArgumentException(
"Email address is invalid."
);
}
}
@Override
public String toString() {
return value;
}
}
A Note About Email Normalization
We normalize the complete address to lowercase for this learning project।
Real-world email normalization can involve provider and domain-specific considerations।
The important principle here is:
Define a deliberate application policy.
Do not assume every normalization rule is universally correct।
Part 9: Learner
Learner is immutable in this project।
Learner.java
package io.liveklass.learner;
public record Learner(
LearnerId id,
String name,
EmailAddress email
) {
public Learner {
if (id == null) {
throw new IllegalArgumentException(
"Learner id is required."
);
}
if (
name == null
|| name.isBlank()
) {
throw new IllegalArgumentException(
"Learner name is required."
);
}
if (email == null) {
throw new IllegalArgumentException(
"Learner email is required."
);
}
name =
name.strip();
}
}
Why Learner Is a Record Here
Our final project does not support:
Rename learner
Change learner email
Suspend learner
Delete learner
So a simple immutable learner representation is enough।
If real lifecycle behavior appears later, a normal class may become more appropriate।
Part 10: EnrollmentId
EnrollmentId.java
package io.liveklass.enrollment;
public record EnrollmentId(
long value
) {
public EnrollmentId {
if (value <= 0) {
throw new IllegalArgumentException(
"Enrollment id must be positive."
);
}
}
@Override
public String toString() {
return Long.toString(
value
);
}
}
Part 11: EnrollmentStatus
Enrollment lifecycle:
ACTIVE
├──→ COMPLETED
└──→ CANCELLED
EnrollmentStatus.java
package io.liveklass.enrollment;
public enum EnrollmentStatus {
ACTIVE,
COMPLETED,
CANCELLED
}
Part 12: EnrollmentSummary
EnrollmentSummary.java
package io.liveklass.enrollment;
import io.liveklass.course.CourseCode;
import io.liveklass.learner.LearnerId;
public record EnrollmentSummary(
EnrollmentId id,
LearnerId learnerId,
CourseCode courseCode,
EnrollmentStatus status
) {
public EnrollmentSummary {
if (id == null) {
throw new IllegalArgumentException(
"Enrollment id is required."
);
}
if (learnerId == null) {
throw new IllegalArgumentException(
"Learner id is required."
);
}
if (courseCode == null) {
throw new IllegalArgumentException(
"Course code is required."
);
}
if (status == null) {
throw new IllegalArgumentException(
"Enrollment status is required."
);
}
}
}
Part 13: Enrollment
Enrollment is a lifecycle-heavy entity।
It should control:
complete()
cancel()
Callers should not receive:
setStatus(...)
Enrollment.java
package io.liveklass.enrollment;
import io.liveklass.course.CourseCode;
import io.liveklass.learner.LearnerId;
public final class Enrollment {
private final EnrollmentId id;
private final LearnerId learnerId;
private final CourseCode courseCode;
private EnrollmentStatus status;
public Enrollment(
EnrollmentId id,
LearnerId learnerId,
CourseCode courseCode
) {
this(
id,
learnerId,
courseCode,
EnrollmentStatus.ACTIVE
);
}
private Enrollment(
EnrollmentId id,
LearnerId learnerId,
CourseCode courseCode,
EnrollmentStatus status
) {
if (id == null) {
throw new IllegalArgumentException(
"Enrollment id is required."
);
}
if (learnerId == null) {
throw new IllegalArgumentException(
"Learner id is required."
);
}
if (courseCode == null) {
throw new IllegalArgumentException(
"Course code is required."
);
}
if (status == null) {
throw new IllegalArgumentException(
"Enrollment status is required."
);
}
this.id =
id;
this.learnerId =
learnerId;
this.courseCode =
courseCode;
this.status =
status;
}
public static Enrollment restore(
EnrollmentId id,
LearnerId learnerId,
CourseCode courseCode,
EnrollmentStatus status
) {
return new Enrollment(
id,
learnerId,
courseCode,
status
);
}
public void complete() {
if (
status
!= EnrollmentStatus.ACTIVE
) {
throw new IllegalStateException(
"Only active enrollment can be completed."
);
}
status =
EnrollmentStatus.COMPLETED;
}
public void cancel() {
if (
status
!= EnrollmentStatus.ACTIVE
) {
throw new IllegalStateException(
"Only active enrollment can be cancelled."
);
}
status =
EnrollmentStatus.CANCELLED;
}
public boolean isActive() {
return status
== EnrollmentStatus.ACTIVE;
}
public boolean isCompleted() {
return status
== EnrollmentStatus.COMPLETED;
}
public boolean isCancelled() {
return status
== EnrollmentStatus.CANCELLED;
}
public EnrollmentSummary summary() {
return new EnrollmentSummary(
id,
learnerId,
courseCode,
status
);
}
public EnrollmentId getId() {
return id;
}
public LearnerId getLearnerId() {
return learnerId;
}
public CourseCode getCourseCode() {
return courseCode;
}
public EnrollmentStatus getStatus() {
return status;
}
}
Why Enrollment.restore()?
Same reason as Course.restore()।
Repository may load:
COMPLETED
We should not reconstruct it by calling:
new Enrollment(...);
enrollment.complete();
Persistence restoration is different from performing a new use case।
Domain Rules vs Cross-Entity Rules
Enrollment knows:
Can ACTIVE become COMPLETED?
Can ACTIVE become CANCELLED?
It does not know:
Does learner exist?
Does course exist?
Is course published?
Is learner already enrolled?
Those require repositories and multiple domain objects।
They belong in:
EnrollmentService
which we will implement next।
Package Structure So Far
After this lesson:
src/main/java/
└── io/liveklass/
├── course/
│ ├── Course.java
│ ├── CourseCode.java
│ ├── CourseStatus.java
│ ├── CourseSummary.java
│ ├── Lesson.java
│ └── LessonId.java
│
├── learner/
│ ├── EmailAddress.java
│ ├── Learner.java
│ └── LearnerId.java
│
└── enrollment/
├── Enrollment.java
├── EnrollmentId.java
├── EnrollmentStatus.java
└── EnrollmentSummary.java
Domain Model Walkthrough
Let's simulate a valid course lifecycle।
Create Course
Course course =
new Course(
new CourseCode(
"java-oop"
),
"Java and OOP Foundation",
499_000L
);
State:
Code: JAVA-OOP
Status: DRAFT
Lessons: 0
Add Lesson
course.addLesson(
new Lesson(
new LessonId(
1L
),
"Introduction to Java",
"Java is a general-purpose programming language."
)
);
Course now owns one immutable lesson।
Publish Course
course.publish();
Status becomes:
PUBLISHED
Attempt to Add Another Lesson
course.addLesson(
new Lesson(
new LessonId(
2L
),
"Variables",
"Variables store values."
)
);
Result:
IllegalStateException
because lessons can only be added while course is DRAFT।
Archive
course.archive();
State:
ARCHIVED
Invalid Archive
Calling:
new Course(...).archive();
fails because:
DRAFT cannot directly become ARCHIVED.
Enrollment Walkthrough
Create learner:
Learner learner =
new Learner(
new LearnerId(
1L
),
"Sakib",
new EmailAddress(
"sakib@example.com"
)
);
Create enrollment:
Enrollment enrollment =
new Enrollment(
new EnrollmentId(
1001L
),
learner.id(),
course.getCode()
);
Status:
ACTIVE
Complete Enrollment
enrollment.complete();
Status:
COMPLETED
Calling:
enrollment.cancel();
after completion fails।
Why No Public Setters?
We deliberately avoid:
course.setStatus(...)
enrollment.setStatus(...)
course.setLessons(...)
because setters expose state without domain meaning।
Compare:
enrollment.setStatus(
EnrollmentStatus.COMPLETED
);
with:
enrollment.complete();
The second allows the entity to enforce:
Only ACTIVE can complete.
Why Course.priceInPaisa Is Final
Our project does not include course price editing।
So:
private final long priceInPaisa;
communicates that price is fixed for the lifetime of this Course instance।
If price editing becomes a requirement later, we can add meaningful behavior such as:
changePrice(...)
rather than exposing a setter prematurely।
Equality Strategy Review
Value Objects
Records automatically provide value equality:
CourseCode
LessonId
LearnerId
EnrollmentId
EmailAddress
Lesson
Learner
Summary records
What About Course Equality?
We intentionally do not override:
equals()
hashCode()
for Course in this project।
Why?
Because Course has mutable state:
title
status
lessons
Blindly including all fields would create unstable equality।
When we need lookup, we use:
CourseCode
as the stable identity value।
What About Enrollment Equality?
Same idea।
We use:
EnrollmentId
for repository keys and identity lookup।
We do not need entity-wide equality for this project।
Record Equality and Lesson
Because Lesson is a record:
Lesson(
id,
title,
content
)
all three components participate in equality।
But duplicate lesson rules deliberately use only:
LessonId
because lesson identity inside a course is based on the ID।
Why Not Use lessons.contains(lesson)?
That would detect exact record equality:
same id
same title
same content
But our rule is:
Two lessons cannot share the same LessonId
even if their titles differ।
So:
containsLesson(
lesson.id()
)
is the correct domain rule।
Defensive Programming Review
Our domain layer defends itself at several boundaries।
Constructor Boundary
Example:
new LearnerId(
-10
);
fails immediately।
Mutation Boundary
Example:
course.publish();
checks publication rules before mutation।
Collection Boundary
Example:
course.getLessons()
returns:
List.copyOf(...)
so callers cannot remove internal lessons।
Restoration Boundary
Example:
Course.restore(...)
still validates stored state।
This protects the domain from corrupted persistence input।
Avoid Repeating Validation Outside Strong Types
Once we have:
CourseCode courseCode
a service should not repeatedly do:
courseCode.value()
.isBlank();
That state is impossible if CourseCode construction succeeded।
Strong types let downstream code trust their invariants।
When to Check Null Again
Even if CourseCode itself cannot contain a null value, a method parameter can still receive:
null
Example:
course.containsLesson(
null
);
So API boundaries may still check the object reference itself when null is not allowed।
Domain Layer Should Not Know Infrastructure
None of these classes import:
java.nio.file.Path
java.nio.file.Files
java.util.Properties
java.sql.*
That is deliberate।
Domain objects should not know how they are persisted।
Domain Layer Should Not Print
None of them should do:
System.out.println(
"Course published"
);
Presentation belongs elsewhere।
Domain method should perform behavior or throw meaningful failures।
Common Mistakes
Adding Setters for Every Field
Breaks controlled lifecycle।
Allowing Course to Store Caller's List Directly
Creates mutation leaks।
Returning Internal Mutable Lists
Breaks encapsulation।
Using Raw Strings for Every Identifier
Scatters validation and meaning।
Using Boolean Lifecycle Flags
Becomes unclear as states grow।
Putting Cross-Entity Rules Inside Enrollment
An enrollment cannot independently know whether a course currently exists or is published।
Bypassing Validation During Restore
Allows corrupted storage to create invalid domain state।
Adding equals() to Entities Without Thinking
Mutable fields can create unstable equality semantics।
Assuming Records Are Deeply Immutable
Records can still hold mutable components।
Our record components here are either immutable values or strings, which keeps them safe।
Practice Exercise 1: Invalid Course
Predict what happens:
new Course(
new CourseCode(
"JAVA"
),
" ",
1000L
);
Answer
Course constructor throws:
IllegalArgumentException
because title is blank।
Practice Exercise 2: Duplicate Lessons
Course course =
new Course(
new CourseCode(
"JAVA"
),
"Java",
1000L
);
course.addLesson(
new Lesson(
new LessonId(
1L
),
"Variables",
"Content A"
)
);
course.addLesson(
new Lesson(
new LessonId(
1L
),
"Loops",
"Content B"
)
);
Result
The second addition fails।
Even though title/content differ, the LessonId is duplicated।
Practice Exercise 3: Publish Empty Course
Course course =
new Course(
new CourseCode(
"JAVA"
),
"Java",
1000L
);
course.publish();
Result
IllegalStateException
because a course must contain at least one lesson।
Practice Exercise 4: Enrollment Transition
Enrollment enrollment =
new Enrollment(
new EnrollmentId(
1L
),
new LearnerId(
1L
),
new CourseCode(
"JAVA"
)
);
enrollment.cancel();
enrollment.complete();
Result
cancel() succeeds।
complete() fails because cancelled enrollment is no longer active।
Practice Exercise 5: Defensive List
List<Lesson> lessons =
course.getLessons();
lessons.clear();
What happens?
Answer
The list returned by:
List.copyOf(...)
is unmodifiable।
A mutation attempt throws:
UnsupportedOperationException
and course state remains unchanged।
Practice Exercise 6: Restoration
Would this be valid?
Course.restore(
new CourseCode(
"JAVA"
),
"Java",
1000L,
CourseStatus.PUBLISHED,
List.of()
);
Answer
No।
The restored state violates the invariant:
Published course must have at least one lesson.
Predict the Result
Question 1
CourseCode first =
new CourseCode(
"java"
);
CourseCode second =
new CourseCode(
" JAVA "
);
System.out.println(
first.equals(
second
)
);
Answer
true
Question 2
EmailAddress first =
new EmailAddress(
" USER@EXAMPLE.COM "
);
System.out.println(
first.value()
);
Answer
user@example.com
under our project normalization policy।
Question 3
Can this compile?
lesson.title =
"New title";
Answer
No।
Record component fields are private and final।
Question 4
Can a published course be archived?
Answer
Yes।
Question 5
Can an archived course be published again?
Answer
No।
publish() requires:
DRAFT
True or False
- Every entity should be a record.
CourseCodeis a value object.Courseowns its lesson collection.Course.getLessons()should expose the internalArrayList.Enrollmentshould allow arbitrary status assignment.Enrollment.complete()should validate the current state.- Repository restoration should bypass all domain validation.
CourseCodeequality is stable.Coursemust overrideequals()for this project.- Cross-entity enrollment rules belong primarily in the application service.
Answers
1. False
2. True
3. True
4. False
5. False
6. True
7. False
8. True
9. False
10. True
Knowledge Check
Question 1
Why is CourseCode a record?
Question 2
Why is Course a normal class?
Question 3
Why does Course own its internal ArrayList?
Question 4
Why does getLessons() return List.copyOf()?
Question 5
Why is CourseStatus better than multiple booleans?
Question 6
Why does Course.restore() exist?
Question 7
Should restore bypass invariants?
Question 8
Why does duplicate lesson validation use LessonId?
Question 9
Why does Enrollment own complete() and cancel()?
Question 10
Why does Enrollment not check if the learner exists?
Question 11
What kind of equality do record value objects provide?
Question 12
Why do we avoid entity equality based on all mutable fields?
Question 13
What is the advantage of strong ID types?
Question 14
Why should domain objects not use Files or Path?
Question 15
What is the main goal of the domain layer?
Knowledge Check Answers
Answer 1
It represents one immutable validated value with natural value equality and hashing।
Answer 2
It owns mutable lifecycle, controlled state transitions, and a mutable internal lesson collection।
Answer 3
So callers cannot mutate course state indirectly through an externally owned list।
Answer 4
To expose lesson data without allowing callers to modify the internal collection।
Answer 5
An enum models one explicit state at a time and avoids invalid combinations of multiple booleans।
Answer 6
To reconstruct persisted state directly without replaying domain transitions।
Answer 7
No. Restored state must still satisfy domain invariants।
Answer 8
Because the business rule says lesson identity inside a course is determined by its ID।
Answer 9
The enrollment owns its lifecycle state and can enforce valid transitions consistently।
Answer 10
Learner existence requires access to external repository/application state, which the entity does not own।
Answer 11
Value-based equality across their components।
Answer 12
Mutable state changes could make equality and hash semantics unstable and misleading।
Answer 13
They encode domain meaning and validation once, preventing invalid primitive values from spreading through the application।
Answer 14
Persistence is an infrastructure concern and should remain independent from domain behavior।
Answer 15
To represent valid domain concepts, protect invariants, and expose meaningful behavior without infrastructure or presentation concerns।
Lesson Summary
এই lesson-এ আমরা Final Project-এর complete domain model তৈরি করেছি।
আমরা implement করেছি:
CourseCode
LessonId
Lesson
CourseStatus
Course
CourseSummary
LearnerId
EmailAddress
Learner
EnrollmentId
EnrollmentStatus
Enrollment
EnrollmentSummary
আমরা শিখেছি:
- Strong identifiers invalid primitive values prevent করে
- Records immutable value objects-এর জন্য useful
- Normal classes lifecycle-heavy entities-এর জন্য better
Courselessons এবং publication lifecycle own করেEnrollmentcompletion/cancellation lifecycle own করে- Domain objects public setters expose করে না
- Internal collections defensively owned এবং safely exposed
Course.restore()এবংEnrollment.restore()persistence reconstruction support করে- Restoration domain validation bypass করে না
- Value objects stable equality provide করে
- Entity equality blindly generate করা প্রয়োজন নেই
- Cross-entity rules domain entity নয়, application service coordinate করবে
- Domain code storage এবং console concerns থেকে independent থাকে
The central domain rule is:
Objects should make valid behavior easy
and invalid behavior difficult.
Next Lesson
পরবর্তী lesson:
Building Repository Contracts and Application Services
আমরা implement করব:
CourseRepositoryLearnerRepositoryEnrollmentRepository- In-memory repository implementations
CourseServiceLearnerServiceEnrollmentService- Meaningful application exceptions
- Duplicate detection
- Cross-entity enrollment rules
- Constructor injection
- End-to-end use-case testing without file storage
Focus থাকবে:
Use-case orchestration without infrastructure coupling