OOP Capstone: Building a Complete Application
Capstone Overview
Congratulations on reaching the final lesson of the Dart OOP tutorial! In this capstone, you’ll build a complete Task Management System that uses every OOP concept you’ve learned: classes, inheritance, interfaces, abstract classes, mixins, generics, sealed classes, design patterns, composition, error handling, immutability, and the SOLID principles. This is a real-world project structure you’d see in a production Dart or Flutter application.
We’ll build the system step by step, explaining which OOP principle each part demonstrates.
Step 1: Domain Models (Value Objects & Entities)
First, we define our core data types. Value objects represent concepts without identity. Entities have unique IDs. All models are immutable with copyWith support.
Core Domain Models
// ---- Value Objects ----
enum Priority implements Comparable<Priority> {
low(1),
medium(2),
high(3),
critical(4);
final int level;
const Priority(this.level);
@override
int compareTo(Priority other) => level.compareTo(other.level);
bool operator >(Priority other) => level > other.level;
bool operator <(Priority other) => level < other.level;
}
enum TaskStatus {
todo,
inProgress,
review,
done,
cancelled;
bool get isActive => this == todo || this == inProgress || this == review;
bool get isCompleted => this == done;
}
class Tag {
final String name;
final String color;
const Tag(this.name, {this.color = '#808080'});
@override
bool operator ==(Object other) =>
other is Tag && other.name == name;
@override
int get hashCode => name.hashCode;
@override
String toString() => name;
}
// ---- Entity ----
class Task {
final String id;
final String title;
final String description;
final Priority priority;
final TaskStatus status;
final List<Tag> tags;
final DateTime createdAt;
final DateTime? dueDate;
final DateTime? completedAt;
final String? assigneeId;
const Task({
required this.id,
required this.title,
this.description = '',
this.priority = Priority.medium,
this.status = TaskStatus.todo,
this.tags = const [],
required this.createdAt,
this.dueDate,
this.completedAt,
this.assigneeId,
});
bool get isOverdue =>
dueDate != null &&
status.isActive &&
DateTime.now().isAfter(dueDate!);
Task copyWith({
String? title,
String? description,
Priority? priority,
TaskStatus? status,
List<Tag>? tags,
DateTime? dueDate,
DateTime? completedAt,
String? assigneeId,
}) => Task(
id: id,
title: title ?? this.title,
description: description ?? this.description,
priority: priority ?? this.priority,
status: status ?? this.status,
tags: tags ?? this.tags,
createdAt: createdAt,
dueDate: dueDate ?? this.dueDate,
completedAt: completedAt ?? this.completedAt,
assigneeId: assigneeId ?? this.assigneeId,
);
@override
bool operator ==(Object other) => other is Task && other.id == id;
@override
int get hashCode => id.hashCode;
@override
String toString() => 'Task($id: $title [$status] $priority)';
}
isOverdue), Immutability (all fields final, copyWith pattern), Value Objects (Tag, Priority), Entity (Task with ID-based equality), Enums with behavior (methods on Priority and TaskStatus).Step 2: Error Handling (Sealed Classes & Result Pattern)
Next, we define a robust error handling system using sealed classes so the compiler ensures we handle every error type.
Error Types and Result
// Sealed error hierarchy -- compiler-checked exhaustiveness
sealed class AppError {
final String message;
const AppError(this.message);
@override
String toString() => '$runtimeType: $message';
}
class NotFoundError extends AppError {
final String entityType;
final String id;
NotFoundError(this.entityType, this.id)
: super('$entityType "$id" not found');
}
class ValidationError extends AppError {
final Map<String, String> fieldErrors;
ValidationError(this.fieldErrors)
: super('Validation failed: ${fieldErrors.entries.map((e) => "${e.key}: ${e.value}").join(", ")}');
}
class PermissionError extends AppError {
final String action;
final String userId;
PermissionError({required this.action, required this.userId})
: super('User "$userId" not permitted to $action');
}
class ConflictError extends AppError {
ConflictError(super.message);
}
// Generic Result type
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}
class Failure<T> extends Result<T> {
final AppError error;
const Failure(this.error);
}
// Convenience extensions
extension ResultOps<T> on Result<T> {
T? get valueOrNull => switch (this) {
Success(value: var v) => v,
Failure() => null,
};
Result<R> map<R>(R Function(T) f) => switch (this) {
Success(value: var v) => Success(f(v)),
Failure(error: var e) => Failure(e),
};
}
Step 3: Interfaces & Contracts (Abstraction)
We define contracts using abstract classes and interfaces. This follows the Dependency Inversion Principle: high-level modules depend on abstractions, not concrete implementations.
Repository and Service Interfaces
// Repository interface -- abstracts data storage
abstract class TaskRepository {
Future<Result<Task>> getById(String id);
Future<Result<List<Task>>> getAll();
Future<Result<Task>> save(Task task);
Future<Result<void>> delete(String id);
Future<Result<List<Task>>> findByStatus(TaskStatus status);
Future<Result<List<Task>>> findByTag(Tag tag);
}
// Notification interface -- abstracts how notifications are sent
abstract class NotificationService {
Future<void> notify(String userId, String title, String message);
}
// ID generator interface
abstract class IdGenerator {
String generate();
}
// Validator interface using generics
abstract class Validator<T> {
Result<T> validate(T value);
}
TaskRepository as an abstract class, the business logic never knows if tasks are stored in memory, a database, or a remote API. You can swap implementations without changing a single line of business code.Step 4: Concrete Implementations (Inheritance & Composition)
Now we implement the interfaces. The in-memory repository is great for testing; a database or API repository would be used in production.
In-Memory Repository Implementation
// Simple ID generator
class UuidGenerator implements IdGenerator {
int _counter = 0;
@override
String generate() {
_counter++;
final timestamp = DateTime.now().millisecondsSinceEpoch;
return 'task_${timestamp}_$_counter';
}
}
// In-memory repository -- perfect for testing
class InMemoryTaskRepository implements TaskRepository {
final Map<String, Task> _store = {};
@override
Future<Result<Task>> getById(String id) async {
final task = _store[id];
if (task == null) {
return Failure(NotFoundError('Task', id));
}
return Success(task);
}
@override
Future<Result<List<Task>>> getAll() async {
return Success(_store.values.toList());
}
@override
Future<Result<Task>> save(Task task) async {
_store[task.id] = task;
return Success(task);
}
@override
Future<Result<void>> delete(String id) async {
if (!_store.containsKey(id)) {
return Failure(NotFoundError('Task', id));
}
_store.remove(id);
return Success(null as void);
}
@override
Future<Result<List<Task>>> findByStatus(TaskStatus status) async {
final tasks = _store.values
.where((t) => t.status == status)
.toList();
return Success(tasks);
}
@override
Future<Result<List<Task>>> findByTag(Tag tag) async {
final tasks = _store.values
.where((t) => t.tags.contains(tag))
.toList();
return Success(tasks);
}
}
// Console notification service
class ConsoleNotificationService implements NotificationService {
@override
Future<void> notify(String userId, String title, String message) async {
print('[Notification to $userId] $title: $message');
}
}
Step 5: Validation (Single Responsibility & Generics)
Each validator has one job: validate one thing. Validators can be composed together. This demonstrates the Single Responsibility Principle and the Strategy Pattern.
Task Validation
class TaskValidator implements Validator<Task> {
@override
Result<Task> validate(Task task) {
final errors = <String, String>{};
if (task.title.trim().isEmpty) {
errors['title'] = 'Title cannot be empty';
}
if (task.title.length > 200) {
errors['title'] = 'Title cannot exceed 200 characters';
}
if (task.dueDate != null && task.dueDate!.isBefore(task.createdAt)) {
errors['dueDate'] = 'Due date cannot be before creation date';
}
if (task.tags.length > 10) {
errors['tags'] = 'Cannot have more than 10 tags';
}
if (errors.isNotEmpty) {
return Failure(ValidationError(errors));
}
return Success(task);
}
}
// Status transition validator -- enforces business rules
class StatusTransitionValidator {
// Valid transitions: todo->inProgress, inProgress->review, etc.
static final Map<TaskStatus, Set<TaskStatus>> _validTransitions = {
TaskStatus.todo: {TaskStatus.inProgress, TaskStatus.cancelled},
TaskStatus.inProgress: {TaskStatus.review, TaskStatus.todo, TaskStatus.cancelled},
TaskStatus.review: {TaskStatus.done, TaskStatus.inProgress},
TaskStatus.done: {}, // Terminal state
TaskStatus.cancelled: {}, // Terminal state
};
Result<void> validateTransition(TaskStatus from, TaskStatus to) {
final valid = _validTransitions[from] ?? {};
if (!valid.contains(to)) {
return Failure(ConflictError(
'Cannot transition from $from to $to. Valid transitions: $valid',
));
}
return Success(null as void);
}
}
Step 6: Mixins (Cross-cutting Concerns)
Mixins add reusable behavior without inheritance. Here we create mixins for logging and timestamping -- behaviors that multiple services might need.
Reusable Mixins
mixin Logging {
String get logPrefix;
void logInfo(String message) {
print('[INFO][$logPrefix] $message');
}
void logWarning(String message) {
print('[WARN][$logPrefix] $message');
}
void logError(String message) {
print('[ERROR][$logPrefix] $message');
}
}
mixin EventTracking {
final List<String> _events = [];
void trackEvent(String event) {
_events.add('[${DateTime.now().toIso8601String()}] $event');
}
List<String> get eventHistory => List.unmodifiable(_events);
}
mixin Statistics {
int _operationCount = 0;
final Stopwatch _stopwatch = Stopwatch();
void startTimer() => _stopwatch.start();
void stopTimer() => _stopwatch.stop();
void incrementOps() => _operationCount++;
int get operationCount => _operationCount;
Duration get totalTime => _stopwatch.elapsed;
double get avgTimePerOp =>
_operationCount > 0
? _stopwatch.elapsedMilliseconds / _operationCount
: 0;
}
Step 7: The Service Layer (Composition & Facade Pattern)
The TaskService is the heart of the application. It composes a repository, validators, notification service, and an ID generator. It uses mixins for logging and tracking. This is the Facade Pattern -- a simple interface to a complex subsystem.
Task Service -- The Core Business Logic
class TaskService with Logging, EventTracking, Statistics {
final TaskRepository _repository;
final NotificationService _notifications;
final IdGenerator _idGenerator;
final TaskValidator _validator = TaskValidator();
final StatusTransitionValidator _transitionValidator = StatusTransitionValidator();
@override
String get logPrefix => 'TaskService';
TaskService({
required TaskRepository repository,
required NotificationService notifications,
required IdGenerator idGenerator,
}) : _repository = repository,
_notifications = notifications,
_idGenerator = idGenerator;
// Create a new task
Future<Result<Task>> createTask({
required String title,
String description = '',
Priority priority = Priority.medium,
List<Tag> tags = const [],
DateTime? dueDate,
String? assigneeId,
}) async {
startTimer();
incrementOps();
final task = Task(
id: _idGenerator.generate(),
title: title,
description: description,
priority: priority,
tags: tags,
createdAt: DateTime.now(),
dueDate: dueDate,
assigneeId: assigneeId,
);
// Validate
final validation = _validator.validate(task);
if (validation is Failure<Task>) {
logWarning('Validation failed for task: ${task.title}');
stopTimer();
return validation;
}
// Save
final result = await _repository.save(task);
if (result is Success<Task>) {
logInfo('Created task: ${task.id} - ${task.title}');
trackEvent('task_created:${task.id}');
// Notify assignee
if (assigneeId != null) {
await _notifications.notify(
assigneeId,
'New Task Assigned',
'You have been assigned: ${task.title}',
);
}
}
stopTimer();
return result;
}
// Update task status with validation
Future<Result<Task>> updateStatus(String taskId, TaskStatus newStatus) async {
incrementOps();
// Get current task
final getResult = await _repository.getById(taskId);
if (getResult is Failure<Task>) return getResult;
final task = (getResult as Success<Task>).value;
// Validate transition
final transitionResult = _transitionValidator.validateTransition(
task.status, newStatus,
);
if (transitionResult is Failure) {
return Failure((transitionResult as Failure).error);
}
// Apply update
final updatedTask = task.copyWith(
status: newStatus,
completedAt: newStatus == TaskStatus.done ? DateTime.now() : null,
);
final saveResult = await _repository.save(updatedTask);
if (saveResult is Success<Task>) {
logInfo('Task ${task.id}: ${task.status} -> $newStatus');
trackEvent('status_changed:${task.id}:$newStatus');
if (task.assigneeId != null && newStatus == TaskStatus.done) {
await _notifications.notify(
task.assigneeId!,
'Task Completed',
'"${task.title}" has been marked as done.',
);
}
}
return saveResult;
}
// Get task statistics
Future<TaskStats> getStatistics() async {
final result = await _repository.getAll();
final tasks = result.valueOrNull ?? [];
return TaskStats(
total: tasks.length,
byStatus: {
for (var status in TaskStatus.values)
status: tasks.where((t) => t.status == status).length,
},
byPriority: {
for (var p in Priority.values)
p: tasks.where((t) => t.priority == p).length,
},
overdue: tasks.where((t) => t.isOverdue).length,
);
}
// Search tasks with filtering
Future<Result<List<Task>>> searchTasks({
String? titleContains,
TaskStatus? status,
Priority? minPriority,
Tag? tag,
}) async {
final result = await _repository.getAll();
if (result is Failure<List<Task>>) return result;
var tasks = (result as Success<List<Task>>).value;
if (titleContains != null) {
tasks = tasks.where((t) =>
t.title.toLowerCase().contains(titleContains.toLowerCase())
).toList();
}
if (status != null) {
tasks = tasks.where((t) => t.status == status).toList();
}
if (minPriority != null) {
tasks = tasks.where((t) => t.priority >= minPriority).toList();
}
if (tag != null) {
tasks = tasks.where((t) => t.tags.contains(tag)).toList();
}
return Success(tasks);
}
}
// Statistics value object
class TaskStats {
final int total;
final Map<TaskStatus, int> byStatus;
final Map<Priority, int> byPriority;
final int overdue;
const TaskStats({
required this.total,
required this.byStatus,
required this.byPriority,
required this.overdue,
});
double get completionRate =>
total > 0 ? (byStatus[TaskStatus.done] ?? 0) / total * 100 : 0;
@override
String toString() => 'TaskStats(total: $total, done: ${byStatus[TaskStatus.done]}, '
'overdue: $overdue, completion: ${completionRate.toStringAsFixed(1)}%)';
}
Step 8: Putting It All Together
Now let’s wire everything together and demonstrate the complete system working.
Running the Complete Application
Future<void> main() async {
// ---- Wire up dependencies (Dependency Injection) ----
final repository = InMemoryTaskRepository();
final notifications = ConsoleNotificationService();
final idGenerator = UuidGenerator();
final taskService = TaskService(
repository: repository,
notifications: notifications,
idGenerator: idGenerator,
);
// ---- Create some tasks ----
print('=== Creating Tasks ===');
final result1 = await taskService.createTask(
title: 'Design the database schema',
description: 'Create ERD and define all tables',
priority: Priority.high,
tags: [Tag('backend'), Tag('database')],
dueDate: DateTime.now().add(Duration(days: 7)),
assigneeId: 'user_alice',
);
final result2 = await taskService.createTask(
title: 'Build the login page',
priority: Priority.critical,
tags: [Tag('frontend'), Tag('auth')],
assigneeId: 'user_bob',
);
final result3 = await taskService.createTask(
title: 'Write unit tests',
priority: Priority.medium,
tags: [Tag('testing')],
);
// ---- Validation in action ----
print('\n=== Validation ===');
final invalidResult = await taskService.createTask(title: '');
switch (invalidResult) {
case Success(value: var task):
print('Created: $task');
case Failure(error: var error):
print('Expected error: $error');
}
// ---- Update task status ----
print('\n=== Status Transitions ===');
final taskId = (result1 as Success<Task>).value.id;
await taskService.updateStatus(taskId, TaskStatus.inProgress);
await taskService.updateStatus(taskId, TaskStatus.review);
await taskService.updateStatus(taskId, TaskStatus.done);
// Invalid transition
final invalidTransition = await taskService.updateStatus(
taskId, TaskStatus.inProgress,
);
switch (invalidTransition) {
case Success():
print('Unexpected success');
case Failure(error: var e):
print('Expected: $e');
}
// ---- Search and filter ----
print('\n=== Search ===');
final highPriority = await taskService.searchTasks(
minPriority: Priority.high,
);
switch (highPriority) {
case Success(value: var tasks):
print('High+ priority tasks:');
for (var t in tasks) {
print(' $t');
}
case Failure(error: var e):
print('Error: $e');
}
// ---- Statistics ----
print('\n=== Statistics ===');
final stats = await taskService.getStatistics();
print(stats);
// ---- Service metrics ----
print('\n=== Service Metrics ===');
print('Operations: ${taskService.operationCount}');
print('Event log: ${taskService.eventHistory.length} events');
for (var event in taskService.eventHistory) {
print(' $event');
}
}
SOLID Principles Review
Let’s review how our application follows all five SOLID principles:
SOLID Principles in Our System
// S - Single Responsibility Principle
// Each class has ONE reason to change:
// - Task: data model
// - TaskValidator: validation rules
// - StatusTransitionValidator: transition rules
// - InMemoryTaskRepository: data storage
// - ConsoleNotificationService: sending notifications
// - TaskService: orchestrating business logic
// O - Open/Closed Principle
// Open for extension, closed for modification:
// - Add new repository: class PostgresRepository implements TaskRepository
// - Add new notifications: class SlackNotification implements NotificationService
// - Add new validators: class DueDateValidator implements Validator<Task>
// None of these require modifying existing code!
// L - Liskov Substitution Principle
// Any implementation can replace its interface:
// TaskRepository repo = InMemoryTaskRepository();
// TaskRepository repo = PostgresTaskRepository(); // Drop-in replacement
// The TaskService works identically with either one.
// I - Interface Segregation Principle
// Interfaces are small and focused:
// - TaskRepository: only CRUD operations
// - NotificationService: only notify()
// - IdGenerator: only generate()
// - Validator<T>: only validate()
// No class is forced to implement methods it does not need.
// D - Dependency Inversion Principle
// High-level (TaskService) depends on abstractions (TaskRepository),
// not concrete classes (InMemoryTaskRepository).
// Dependencies are injected through the constructor.
// This makes testing easy -- just inject mocks!
Testing the System
Because we used dependency injection and interfaces, every component is independently testable. Here’s how you’d test the task service with mock dependencies.
Testing with Mock Implementations
// Mock notification service for testing
class MockNotificationService implements NotificationService {
final List<({String userId, String title, String message})> sent = [];
@override
Future<void> notify(String userId, String title, String message) async {
sent.add((userId: userId, title: title, message: message));
}
}
// Fixed ID generator for deterministic tests
class FixedIdGenerator implements IdGenerator {
final List<String> _ids;
int _index = 0;
FixedIdGenerator(this._ids);
@override
String generate() => _ids[_index++];
}
// Test example
Future<void> testCreateTask() async {
// Arrange -- create service with test dependencies
final repo = InMemoryTaskRepository();
final notifications = MockNotificationService();
final idGen = FixedIdGenerator(['test-id-1', 'test-id-2']);
final service = TaskService(
repository: repo,
notifications: notifications,
idGenerator: idGen,
);
// Act -- create a task
final result = await service.createTask(
title: 'Test Task',
assigneeId: 'user_123',
);
// Assert
switch (result) {
case Success(value: var task):
assert(task.id == 'test-id-1');
assert(task.title == 'Test Task');
assert(task.status == TaskStatus.todo);
assert(notifications.sent.length == 1);
assert(notifications.sent.first.userId == 'user_123');
print('All assertions passed!');
case Failure(error: var e):
print('Test failed: $e');
}
}
Architecture Summary
Here’s a summary of the complete system architecture and which OOP concepts each layer uses:
Complete Architecture Map
// LAYER 1: Domain Models
// Concepts: Classes, Enums, Value Objects, Entities, Immutability, copyWith
// Files: task.dart, priority.dart, tag.dart, task_status.dart
// LAYER 2: Error Handling
// Concepts: Sealed Classes, Generics, Pattern Matching, Result Pattern
// Files: app_error.dart, result.dart
// LAYER 3: Contracts (Interfaces)
// Concepts: Abstract Classes, Generics, Dependency Inversion
// Files: task_repository.dart, notification_service.dart, validator.dart
// LAYER 4: Implementations
// Concepts: Inheritance (implements), Encapsulation, Single Responsibility
// Files: in_memory_repo.dart, console_notifications.dart, uuid_generator.dart
// LAYER 5: Validation
// Concepts: Strategy Pattern, Single Responsibility, Composition
// Files: task_validator.dart, status_transition_validator.dart
// LAYER 6: Cross-cutting Concerns
// Concepts: Mixins, Separation of Concerns
// Files: logging.dart, event_tracking.dart, statistics.dart
// LAYER 7: Service Layer (Business Logic)
// Concepts: Facade Pattern, Composition, Dependency Injection, Mixins
// Files: task_service.dart
// LAYER 8: Application Entry Point
// Concepts: Dependency Injection, Wiring
// Files: main.dart
// OOP Concepts Summary:
// - Classes & Objects .............. Task, Tag, TaskService
// - Encapsulation .................. Private fields (_store, _items)
// - Inheritance .................... implements TaskRepository
// - Polymorphism ................... NotificationService (multiple implementations)
// - Abstraction .................... Abstract classes (TaskRepository, Validator)
// - Generics ...................... Result<T>, Validator<T>
// - Sealed Classes ................ AppError hierarchy
// - Mixins ........................ Logging, EventTracking, Statistics
// - Enums with Behavior ........... Priority, TaskStatus
// - Immutability & copyWith ....... Task, TaskStats
// - Value Objects vs Entities ..... Tag vs Task
// - Design Patterns ............... Facade, Strategy, Repository, Observer
// - SOLID Principles .............. All five demonstrated
// - Error Handling ................ Result pattern, sealed errors
// - Dependency Injection .......... Constructor injection in TaskService