Final Project
Building Repository Contracts and Application Services
আপনি একটি free preview lesson দেখছেন।
Lesson Overview
আগের lesson-এ আমরা Final Project-এর domain model তৈরি করেছি:
Course
Lesson
Learner
Enrollment
এবার আমরা application layer তৈরি করব।
এই lesson-এ implement করব:
CourseRepositoryLearnerRepositoryEnrollmentRepository- In-memory repository implementations
- Application exceptions
CourseServiceLearnerServiceEnrollmentService- Duplicate detection
- Cross-entity enrollment rules
- Constructor injection
এখনও file storage ব্যবহার করব না।
প্রথমে goal:
Domain + use cases
correctly কাজ করছে কিনা verify করা।
পরের lesson-এ একই repository contracts ব্যবহার করে file persistence implement করব।
Why Start with In-Memory Storage?
যদি domain logic এবং file I/O একসঙ্গে build করি, bug কোথায় হচ্ছে বোঝা কঠিন হতে পারে।
Problem হতে পারে:
Business rule
Repository lookup
Path handling
Serialization
File parsing
Better progression:
Domain
↓
Repository contracts
↓
In-memory repositories
↓
Services
↓
File repositories
এভাবে complexity ধীরে ধীরে বাড়ে।
Repository as a Contract
Repository application-কে বলে:
কোন persistence capability available?
It does not say:
Data কীভাবে stored হবে?
Example:
Course findByCode(
CourseCode courseCode
);
Implementation হতে পারে:
HashMap
File
Database
Service-এর জানার প্রয়োজন নেই।
1. Course Repository
CourseRepository.java
package io.liveklass.course;
import java.util.List;
public interface CourseRepository {
void save(
Course course
);
Course findByCode(
CourseCode courseCode
);
List<Course> findAll();
}
Current use cases-এর জন্য এটুকুই যথেষ্ট।
2. Learner Repository
Learner registration-এর সময় আমরা check করব:
Duplicate learner ID
Duplicate email
তাই repository-তে email lookup দরকার।
LearnerRepository.java
package io.liveklass.learner;
import java.util.List;
public interface LearnerRepository {
void save(
Learner learner
);
Learner findById(
LearnerId learnerId
);
Learner findByEmail(
EmailAddress email
);
List<Learner> findAll();
}
Why findByEmail()?
Service নিজে এমন করতে পারত:
repository.findAll()
.stream()
.filter(...)
কিন্তু email lookup persistence capability।
Better:
repository.findByEmail(
email
);
Repository contract actual use case reflect করে।
3. Enrollment Repository
Enrollment creation-এর একটি important rule:
এক learner একই course-এ দ্বিতীয়বার enroll করতে পারবে না।
EnrollmentRepository.java
package io.liveklass.enrollment;
import io.liveklass.course.CourseCode;
import io.liveklass.learner.LearnerId;
import java.util.List;
public interface EnrollmentRepository {
void save(
Enrollment enrollment
);
Enrollment findById(
EnrollmentId enrollmentId
);
boolean existsByLearnerAndCourse(
LearnerId learnerId,
CourseCode courseCode
);
List<Enrollment> findAll();
}
Repository Methods Should Come from Real Needs
Do not design dozens of hypothetical methods upfront।
Good:
existsByLearnerAndCourse(...)
because current business rule requires it।
Do not add:
findByStatusAndCourseAndLearnerAndDate...
until a real use case requires it।
Application Exceptions
Meaningful exception types help distinguish failure categories।
We will use:
CourseNotFoundException
DuplicateCourseException
LearnerNotFoundException
DuplicateLearnerException
EnrollmentNotFoundException
DuplicateEnrollmentException
DuplicateEnrollmentIdException
CourseNotAvailableForEnrollmentException
CourseNotFoundException.java
package io.liveklass.course;
public final class CourseNotFoundException
extends RuntimeException {
public CourseNotFoundException(
CourseCode courseCode
) {
super(
"Course not found: "
+ courseCode
+ "."
);
}
}
DuplicateCourseException.java
package io.liveklass.course;
public final class DuplicateCourseException
extends RuntimeException {
public DuplicateCourseException(
CourseCode courseCode
) {
super(
"Course already exists: "
+ courseCode
+ "."
);
}
}
LearnerNotFoundException.java
package io.liveklass.learner;
public final class LearnerNotFoundException
extends RuntimeException {
public LearnerNotFoundException(
LearnerId learnerId
) {
super(
"Learner not found: "
+ learnerId
+ "."
);
}
}
DuplicateLearnerException.java
package io.liveklass.learner;
public final class DuplicateLearnerException
extends RuntimeException {
public DuplicateLearnerException(
String message
) {
super(
message
);
}
}
EnrollmentNotFoundException.java
package io.liveklass.enrollment;
public final class EnrollmentNotFoundException
extends RuntimeException {
public EnrollmentNotFoundException(
EnrollmentId enrollmentId
) {
super(
"Enrollment not found: "
+ enrollmentId
+ "."
);
}
}
DuplicateEnrollmentException.java
package io.liveklass.enrollment;
import io.liveklass.course.CourseCode;
import io.liveklass.learner.LearnerId;
public final class DuplicateEnrollmentException
extends RuntimeException {
public DuplicateEnrollmentException(
LearnerId learnerId,
CourseCode courseCode
) {
super(
"Learner "
+ learnerId
+ " is already enrolled in course "
+ courseCode
+ "."
);
}
}
DuplicateEnrollmentIdException.java
package io.liveklass.enrollment;
public final class DuplicateEnrollmentIdException
extends RuntimeException {
public DuplicateEnrollmentIdException(
EnrollmentId enrollmentId
) {
super(
"Enrollment id already exists: "
+ enrollmentId
+ "."
);
}
}
CourseNotAvailableForEnrollmentException.java
package io.liveklass.enrollment;
import io.liveklass.course.CourseCode;
public final class CourseNotAvailableForEnrollmentException
extends RuntimeException {
public CourseNotAvailableForEnrollmentException(
CourseCode courseCode
) {
super(
"Course is not available for enrollment: "
+ courseCode
+ "."
);
}
}
In-Memory Repositories
Now repository contracts-এর simple implementations তৈরি করি।
4. In-Memory Course Repository
InMemoryCourseRepository.java
package io.liveklass.course.storage;
import io.liveklass.course.Course;
import io.liveklass.course.CourseCode;
import io.liveklass.course.CourseRepository;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class InMemoryCourseRepository
implements CourseRepository {
private final Map<CourseCode, Course> courses =
new LinkedHashMap<>();
@Override
public void save(
Course course
) {
if (course == null) {
throw new IllegalArgumentException(
"Course is required."
);
}
courses.put(
course.getCode(),
course
);
}
@Override
public Course findByCode(
CourseCode courseCode
) {
if (courseCode == null) {
throw new IllegalArgumentException(
"Course code is required."
);
}
return courses.get(
courseCode
);
}
@Override
public List<Course> findAll() {
return new ArrayList<>(
courses.values()
);
}
}
LinkedHashMap insertion order preserve করে, তাই console output predictable হবে।
5. In-Memory Learner Repository
InMemoryLearnerRepository.java
package io.liveklass.learner.storage;
import io.liveklass.learner.EmailAddress;
import io.liveklass.learner.Learner;
import io.liveklass.learner.LearnerId;
import io.liveklass.learner.LearnerRepository;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class InMemoryLearnerRepository
implements LearnerRepository {
private final Map<LearnerId, Learner> learners =
new LinkedHashMap<>();
@Override
public void save(
Learner learner
) {
if (learner == null) {
throw new IllegalArgumentException(
"Learner is required."
);
}
learners.put(
learner.id(),
learner
);
}
@Override
public Learner findById(
LearnerId learnerId
) {
if (learnerId == null) {
throw new IllegalArgumentException(
"Learner id is required."
);
}
return learners.get(
learnerId
);
}
@Override
public Learner findByEmail(
EmailAddress email
) {
if (email == null) {
throw new IllegalArgumentException(
"Email is required."
);
}
return learners.values()
.stream()
.filter(
learner ->
learner.email()
.equals(
email
)
)
.findFirst()
.orElse(
null
);
}
@Override
public List<Learner> findAll() {
return new ArrayList<>(
learners.values()
);
}
}
6. In-Memory Enrollment Repository
InMemoryEnrollmentRepository.java
package io.liveklass.enrollment.storage;
import io.liveklass.course.CourseCode;
import io.liveklass.enrollment.Enrollment;
import io.liveklass.enrollment.EnrollmentId;
import io.liveklass.enrollment.EnrollmentRepository;
import io.liveklass.learner.LearnerId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class InMemoryEnrollmentRepository
implements EnrollmentRepository {
private final Map<EnrollmentId, Enrollment> enrollments =
new LinkedHashMap<>();
@Override
public void save(
Enrollment enrollment
) {
if (enrollment == null) {
throw new IllegalArgumentException(
"Enrollment is required."
);
}
enrollments.put(
enrollment.getId(),
enrollment
);
}
@Override
public Enrollment findById(
EnrollmentId enrollmentId
) {
if (enrollmentId == null) {
throw new IllegalArgumentException(
"Enrollment id is required."
);
}
return enrollments.get(
enrollmentId
);
}
@Override
public boolean existsByLearnerAndCourse(
LearnerId learnerId,
CourseCode courseCode
) {
if (
learnerId == null
|| courseCode == null
) {
throw new IllegalArgumentException(
"Learner id and course code are required."
);
}
return enrollments.values()
.stream()
.anyMatch(
enrollment ->
enrollment.getLearnerId()
.equals(
learnerId
)
&& enrollment.getCourseCode()
.equals(
courseCode
)
);
}
@Override
public List<Enrollment> findAll() {
return new ArrayList<>(
enrollments.values()
);
}
}
What Repositories Should Not Do
Repositories should not decide:
Can a course publish?
Can an enrollment complete?
Can a learner enroll in a draft course?
They provide persistence operations।
Domain objects and application services own business behavior।
7. Course Service
Use cases:
Create course
Add lesson
Publish
Archive
Find
List
CourseService.java
package io.liveklass.course;
import java.util.List;
public final class CourseService {
private final CourseRepository repository;
public CourseService(
CourseRepository repository
) {
if (repository == null) {
throw new IllegalArgumentException(
"Course repository is required."
);
}
this.repository =
repository;
}
public void createCourse(
CourseCode courseCode,
String title,
long priceInPaisa
) {
if (courseCode == null) {
throw new IllegalArgumentException(
"Course code is required."
);
}
if (
repository.findByCode(
courseCode
)
!= null
) {
throw new DuplicateCourseException(
courseCode
);
}
repository.save(
new Course(
courseCode,
title,
priceInPaisa
)
);
}
public void addLesson(
CourseCode courseCode,
Lesson lesson
) {
Course course =
requireCourse(
courseCode
);
course.addLesson(
lesson
);
repository.save(
course
);
}
public void publishCourse(
CourseCode courseCode
) {
Course course =
requireCourse(
courseCode
);
course.publish();
repository.save(
course
);
}
public void archiveCourse(
CourseCode courseCode
) {
Course course =
requireCourse(
courseCode
);
course.archive();
repository.save(
course
);
}
public CourseSummary findCourse(
CourseCode courseCode
) {
return requireCourse(
courseCode
)
.summary();
}
public List<CourseSummary> findAllCourses() {
return repository.findAll()
.stream()
.map(
Course::summary
)
.toList();
}
private Course requireCourse(
CourseCode courseCode
) {
if (courseCode == null) {
throw new IllegalArgumentException(
"Course code is required."
);
}
Course course =
repository.findByCode(
courseCode
);
if (course == null) {
throw new CourseNotFoundException(
courseCode
);
}
return course;
}
}
Service Does Not Duplicate Domain Rules
Notice:
publishCourse(...)
does not manually check:
Course status
Lesson count
It simply calls:
course.publish();
Course owns those invariants।
Service owns:
Find
Coordinate
Persist
8. Learner Service
Rules:
Learner ID unique
Email unique
LearnerService.java
package io.liveklass.learner;
import java.util.List;
public final class LearnerService {
private final LearnerRepository repository;
public LearnerService(
LearnerRepository repository
) {
if (repository == null) {
throw new IllegalArgumentException(
"Learner repository is required."
);
}
this.repository =
repository;
}
public void registerLearner(
LearnerId learnerId,
String name,
EmailAddress email
) {
if (
learnerId == null
|| email == null
) {
throw new IllegalArgumentException(
"Learner id and email are required."
);
}
if (
repository.findById(
learnerId
)
!= null
) {
throw new DuplicateLearnerException(
"Learner id already exists: "
+ learnerId
+ "."
);
}
if (
repository.findByEmail(
email
)
!= null
) {
throw new DuplicateLearnerException(
"Learner email already exists: "
+ email
+ "."
);
}
repository.save(
new Learner(
learnerId,
name,
email
)
);
}
public Learner findLearner(
LearnerId learnerId
) {
Learner learner =
repository.findById(
learnerId
);
if (learner == null) {
throw new LearnerNotFoundException(
learnerId
);
}
return learner;
}
public List<Learner> findAllLearners() {
return List.copyOf(
repository.findAll()
);
}
}
Learner immutable হওয়ায় service থেকে সরাসরি return করা safe।
9. Enrollment Service
এই service cross-entity rules coordinate করবে।
Enrollment creation-এর আগে:
- Enrollment ID unique হতে হবে
- Learner exist করতে হবে
- Course exist করতে হবে
- Course published হতে হবে
- Same learner-course enrollment already থাকা যাবে না
EnrollmentService.java
package io.liveklass.enrollment;
import io.liveklass.course.Course;
import io.liveklass.course.CourseCode;
import io.liveklass.course.CourseNotFoundException;
import io.liveklass.course.CourseRepository;
import io.liveklass.learner.Learner;
import io.liveklass.learner.LearnerId;
import io.liveklass.learner.LearnerNotFoundException;
import io.liveklass.learner.LearnerRepository;
import java.util.List;
public final class EnrollmentService {
private final CourseRepository courseRepository;
private final LearnerRepository learnerRepository;
private final EnrollmentRepository enrollmentRepository;
public EnrollmentService(
CourseRepository courseRepository,
LearnerRepository learnerRepository,
EnrollmentRepository enrollmentRepository
) {
if (
courseRepository == null
|| learnerRepository == null
|| enrollmentRepository == null
) {
throw new IllegalArgumentException(
"Enrollment service repositories are required."
);
}
this.courseRepository =
courseRepository;
this.learnerRepository =
learnerRepository;
this.enrollmentRepository =
enrollmentRepository;
}
public void enroll(
EnrollmentId enrollmentId,
LearnerId learnerId,
CourseCode courseCode
) {
if (
enrollmentId == null
|| learnerId == null
|| courseCode == null
) {
throw new IllegalArgumentException(
"Enrollment id, learner id, and course code are required."
);
}
if (
enrollmentRepository.findById(
enrollmentId
)
!= null
) {
throw new DuplicateEnrollmentIdException(
enrollmentId
);
}
requireLearner(
learnerId
);
Course course =
requireCourse(
courseCode
);
if (
!course.isPublished()
) {
throw new CourseNotAvailableForEnrollmentException(
courseCode
);
}
if (
enrollmentRepository
.existsByLearnerAndCourse(
learnerId,
courseCode
)
) {
throw new DuplicateEnrollmentException(
learnerId,
courseCode
);
}
enrollmentRepository.save(
new Enrollment(
enrollmentId,
learnerId,
courseCode
)
);
}
public void completeEnrollment(
EnrollmentId enrollmentId
) {
Enrollment enrollment =
requireEnrollment(
enrollmentId
);
enrollment.complete();
enrollmentRepository.save(
enrollment
);
}
public void cancelEnrollment(
EnrollmentId enrollmentId
) {
Enrollment enrollment =
requireEnrollment(
enrollmentId
);
enrollment.cancel();
enrollmentRepository.save(
enrollment
);
}
public EnrollmentSummary findEnrollment(
EnrollmentId enrollmentId
) {
return requireEnrollment(
enrollmentId
)
.summary();
}
public List<EnrollmentSummary> findAllEnrollments() {
return enrollmentRepository.findAll()
.stream()
.map(
Enrollment::summary
)
.toList();
}
private Learner requireLearner(
LearnerId learnerId
) {
Learner learner =
learnerRepository.findById(
learnerId
);
if (learner == null) {
throw new LearnerNotFoundException(
learnerId
);
}
return learner;
}
private Course requireCourse(
CourseCode courseCode
) {
Course course =
courseRepository.findByCode(
courseCode
);
if (course == null) {
throw new CourseNotFoundException(
courseCode
);
}
return course;
}
private Enrollment requireEnrollment(
EnrollmentId enrollmentId
) {
if (enrollmentId == null) {
throw new IllegalArgumentException(
"Enrollment id is required."
);
}
Enrollment enrollment =
enrollmentRepository.findById(
enrollmentId
);
if (enrollment == null) {
throw new EnrollmentNotFoundException(
enrollmentId
);
}
return enrollment;
}
}
Why Enrollment Needs Three Repositories
The enrollment use case needs to answer:
Does learner exist?
Does course exist?
Is course published?
Is learner already enrolled?
No single Enrollment object owns all this information।
Therefore:
EnrollmentService
coordinates the required collaborators।
Domain Rule vs Cross-Entity Rule
Useful rule:
Rule depends only on one entity's state
→ entity usually owns it
Rule requires multiple entities/repositories
→ application service usually coordinates it
Examples:
ACTIVE → COMPLETED
belongs to:
Enrollment.complete()
But:
Only published courses accept enrollment
belongs to enrollment use-case coordination।
End-to-End Example
Now we can run the application entirely in memory।
CourseRepository courseRepository =
new InMemoryCourseRepository();
LearnerRepository learnerRepository =
new InMemoryLearnerRepository();
EnrollmentRepository enrollmentRepository =
new InMemoryEnrollmentRepository();
CourseService courseService =
new CourseService(
courseRepository
);
LearnerService learnerService =
new LearnerService(
learnerRepository
);
EnrollmentService enrollmentService =
new EnrollmentService(
courseRepository,
learnerRepository,
enrollmentRepository
);
Create and Publish Course
CourseCode courseCode =
new CourseCode(
"java-oop"
);
courseService.createCourse(
courseCode,
"Java and OOP Foundation",
499_000L
);
courseService.addLesson(
courseCode,
new Lesson(
new LessonId(
1L
),
"Introduction to Java",
"Java is a general-purpose programming language."
)
);
courseService.publishCourse(
courseCode
);
Register Learner
LearnerId learnerId =
new LearnerId(
1L
);
learnerService.registerLearner(
learnerId,
"Sakib",
new EmailAddress(
"sakib@example.com"
)
);
Create Enrollment
EnrollmentId enrollmentId =
new EnrollmentId(
1001L
);
enrollmentService.enroll(
enrollmentId,
learnerId,
courseCode
);
Complete Enrollment
enrollmentService.completeEnrollment(
enrollmentId
);
Result:
COMPLETED
What Happens with a Draft Course?
If course has not been published:
enrollmentService.enroll(...)
throws:
CourseNotAvailableForEnrollmentException
What Happens with Missing Learner?
Throws:
LearnerNotFoundException
What Happens with Duplicate Enrollment?
If same learner tries another enrollment for the same course:
DuplicateEnrollmentException
is thrown।
Our current rule treats even a previous cancelled enrollment as an existing relationship।
If requirements later allow re-enrollment after cancellation, repository query semantics must change।
Constructor Injection
Notice:
new CourseService(
courseRepository
);
and:
new EnrollmentService(
courseRepository,
learnerRepository,
enrollmentRepository
);
Services do not construct their own repositories।
This is plain Java dependency injection।
No framework is required।
Why This Design Is Ready for File Storage
Currently:
CourseRepository courseRepository =
new InMemoryCourseRepository();
Next lesson we can use:
CourseRepository courseRepository =
new FileCourseRepository(
path
);
CourseService will remain unchanged।
Same for learner and enrollment services।
That is the practical value of depending on repository contracts।
Common Mistakes
Service Constructs File Repository
Bad:
private final FileCourseRepository repository =
new FileCourseRepository(...);
This couples application behavior to file storage।
Service Reimplements Entity Rules
Bad:
if (
enrollment.getStatus()
== ACTIVE
) {
enrollment.setStatus(
COMPLETED
);
}
Better:
enrollment.complete();
Entity Calls Repository
Bad:
enrollment.complete(
repository
);
Entity should change its state; service coordinates persistence।
Repository Makes Business Decisions
Repository should not decide whether a course may publish।
Generic Repository for Everything
Avoid:
Object find(
String type,
String id
);
Focused repository contracts preserve type safety and meaning।
Practice Exercises
Exercise 1
Create the same course twice using:
java
JAVA
What should happen?
Answer
Second creation throws:
DuplicateCourseException
because CourseCode normalizes both to the same value।
Exercise 2
Register two learners using:
subu@example.com
SUBU@EXAMPLE.COM
What happens?
Answer
Second registration fails under our email normalization policy।
Exercise 3
Try enrolling a learner before publishing the course।
Answer
CourseNotAvailableForEnrollmentException
Exercise 4
Complete the same enrollment twice।
Answer
Second call fails because Enrollment.complete() only accepts ACTIVE state।
Exercise 5
Could FileCourseRepository replace InMemoryCourseRepository without changing CourseService?
Answer
Yes, provided it correctly implements:
CourseRepository
Knowledge Check
Question 1
What does a repository interface represent?
Question 2
Why use in-memory repositories first?
Question 3
Why does learner repository support email lookup?
Question 4
Why does enrollment repository expose existsByLearnerAndCourse()?
Question 5
Who owns course publication rules?
Question 6
Who checks whether a course is published before enrollment?
Question 7
Who owns ACTIVE → COMPLETED transition?
Question 8
Why do services save after domain mutation?
Question 9
Why should services depend on repository interfaces?
Question 10
Does constructor injection require Spring?
Answers
Answer 1
It describes persistence capabilities required by application code without exposing the storage implementation।
Answer 2
To verify domain and application behavior before adding file-system complexity।
Answer 3
Because learner registration must enforce email uniqueness।
Answer 4
Because duplicate learner-course enrollment is a real use-case rule।
Answer 5
Course।
Answer 6
EnrollmentService, because the rule spans the enrollment use case and course state।
Answer 7
Enrollment।
Answer 8
The entity changes in memory; the application service coordinates persistence of the resulting state।
Answer 9
So storage implementations can change without rewriting use-case logic।
Answer 10
No. Constructor injection is plain Java।
Lesson Summary
এই lesson-এ আমরা implement করেছি:
CourseRepository
LearnerRepository
EnrollmentRepository
InMemoryCourseRepository
InMemoryLearnerRepository
InMemoryEnrollmentRepository
CourseService
LearnerService
EnrollmentService
এবং meaningful application failures।
আমরা শিখেছি:
- Repository contracts storage mechanism hide করে
- Repository methods real application needs থেকে derive করা উচিত
- In-memory implementations business logic test করা সহজ করে
- Application services use-case orchestration করে
- Domain entities নিজের local state transitions protect করে
- Cross-entity rules services coordinate করে
- Constructor injection dependencies explicit করে
- Services concrete storage implementation-এর ওপর depend করে না
- Duplicate entity ID এবং duplicate relationship different failure হতে পারে
- Repository implementation change করলেও service design unchanged থাকতে পারে
Core application flow:
Find required entities
→ enforce cross-entity rules
→ invoke domain behavior
→ persist resulting state
Next Lesson
পরবর্তী lesson:
Implementing File-Based Persistence
আমরা implement করব:
FileCourseRepositoryFileLearnerRepositoryFileEnrollmentRepository- Safe paths
- UTF-8
Properties- Serialization and deserialization
restore()- Missing-file handling
- Corrupted-data handling
- Atomic replacement
findAll()- Repository-specific queries
এবং সবচেয়ে গুরুত্বপূর্ণ:
CourseService
LearnerService
EnrollmentService
একটিও পরিবর্তন করতে হবে না।