Final Project

Building the Domain Model

ReadingPreview

You are viewing a free preview lesson.

Lesson Overview

Final Project-এর architecture design করার পর এবার আমরা domain layer implement করব।

এই lesson-এ কোনো file storage থাকবে না।

কোনো console menu থাকবে না।

কোনো repository implementationও থাকবে না।

Focus থাকবে শুধু:

Domain correctness

আমরা তৈরি করব:

  • CourseCode
  • LessonId
  • Lesson
  • CourseStatus
  • Course
  • CourseSummary
  • LearnerId
  • EmailAddress
  • Learner
  • EnrollmentId
  • EnrollmentStatus
  • Enrollment
  • EnrollmentSummary

এই 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 DRAFT can publish
  • Only PUBLISHED can 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

  1. Every entity should be a record.
  2. CourseCode is a value object.
  3. Course owns its lesson collection.
  4. Course.getLessons() should expose the internal ArrayList.
  5. Enrollment should allow arbitrary status assignment.
  6. Enrollment.complete() should validate the current state.
  7. Repository restoration should bypass all domain validation.
  8. CourseCode equality is stable.
  9. Course must override equals() for this project.
  10. 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
  • Course lessons এবং publication lifecycle own করে
  • Enrollment completion/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 করব:

  • CourseRepository
  • LearnerRepository
  • EnrollmentRepository
  • In-memory repository implementations
  • CourseService
  • LearnerService
  • EnrollmentService
  • 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