🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
71 KiB
ColaFlow M1 Architecture Design
Version: 1.0 Date: 2025-11-02 Milestone: M1 - Core Project Management Module Duration: 8 weeks (Sprints 1-4)
Executive Summary
This document defines the complete system architecture for ColaFlow M1, implementing the Core Project Management Module. The architecture leverages modern technologies: .NET 9 with Domain-Driven Design (DDD) for the backend, PostgreSQL for data persistence, React 19 + Next.js 15 for the frontend, and REST + SignalR for API communication.
Key Architecture Decisions
| Decision | Technology | Rationale |
|---|---|---|
| Backend Framework | .NET 9 with Clean Architecture | Enterprise-grade, excellent DDD support, strong typing |
| Architectural Pattern | DDD + CQRS + Event Sourcing | Domain complexity, audit trail requirements, scalability |
| Primary Database | PostgreSQL 16+ | ACID transactions, JSONB flexibility, excellent for hierarchies |
| Caching | Redis 7+ | Session management, real-time scaling, pub/sub |
| Frontend Framework | React 19 + Next.js 15 | Largest community, excellent DX, SSR/SSG capabilities |
| UI Components | shadcn/ui + Radix UI + Tailwind | Component ownership, accessibility, modern design |
| State Management | TanStack Query + Zustand | Server state + client state separation |
| API Protocol | REST with OpenAPI 3.1 | Broad compatibility, excellent tooling, MCP-ready |
| Real-time | SignalR (WebSockets/HTTP2) | Native .NET integration, automatic fallback |
1. System Architecture Overview
1.1 High-Level Architecture
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT LAYER │
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ Web Browser │ │ Desktop App │ │ Mobile App │ │
│ │ (Next.js 15) │ │ (Future) │ │ (Future) │ │
│ └────────┬─────────┘ └────────┬─────────┘ └───────┬───────┘ │
└───────────┼──────────────────────┼─────────────────────┼─────────┘
│ │ │
│ HTTPS / WSS │ │
└──────────────────────┴─────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ API GATEWAY │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ASP.NET Core 9 API Gateway (Future M2+) │ │
│ │ - Rate Limiting - Authentication - Routing │ │
│ └──────────────────────────────────────────────────────────┘ │
└───────────────────────────────┬─────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ ColaFlow.API (.NET 9) │ │
│ │ ┌────────────────┐ ┌─────────────┐ ┌────────────────┐ │ │
│ │ │ REST API │ │ SignalR │ │ OpenAPI │ │ │
│ │ │ Controllers │ │ Hubs │ │ Documentation │ │ │
│ │ └────────────────┘ └─────────────┘ └────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ APPLICATION SERVICES LAYER │ │ │
│ │ │ (CQRS - Commands & Queries via MediatR) │ │ │
│ │ │ - Project Management - User Management │ │ │
│ │ │ - Workflow Engine - Audit Logging │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ DOMAIN LAYER │ │ │
│ │ │ (Business Logic - Framework Independent) │ │ │
│ │ │ - Aggregates - Entities - Value Objects │ │ │
│ │ │ - Domain Events - Domain Services │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ INFRASTRUCTURE LAYER │ │ │
│ │ │ - EF Core Repositories - Event Store │ │ │
│ │ │ - External Services - Cross-cutting Concerns │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└───────────────────────────────┬─────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ PostgreSQL 16 │ │ Redis 7 │ │ File Storage │ │
│ │ (Primary DB) │ │ (Cache/Session) │ │ (Attachments)│ │
│ │ - Transactional │ │ - SignalR Backpl│ │ - MinIO/S3 │ │
│ │ - Event Store │ │ - Rate Limiting │ │ (Future) │ │
│ │ - JSONB Support │ │ - Pub/Sub │ │ │ │
│ └──────────────────┘ └──────────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────────┘
1.2 Architecture Principles
- Clean Architecture: Separation of concerns with dependency inversion
- Domain-Driven Design: Rich domain model, ubiquitous language
- CQRS: Separate read and write models for performance
- Event Sourcing: Complete audit trail via domain events
- API-First: OpenAPI specification drives implementation
- Testability: All layers independently testable
- Scalability: Stateless services, horizontal scaling ready
2. Backend Architecture (.NET 9 with DDD)
2.1 Clean Architecture Layers
ColaFlow.sln
│
├── src/
│ ├── ColaFlow.Domain/ # Core business logic (innermost layer)
│ │ ├── Aggregates/
│ │ │ ├── ProjectAggregate/
│ │ │ │ ├── Project.cs # Aggregate Root
│ │ │ │ ├── Epic.cs # Entity
│ │ │ │ ├── Story.cs # Entity
│ │ │ │ ├── Task.cs # Entity
│ │ │ │ └── Subtask.cs # Entity
│ │ │ ├── UserAggregate/
│ │ │ └── WorkflowAggregate/
│ │ ├── ValueObjects/
│ │ │ ├── ProjectId.cs
│ │ │ ├── TaskPriority.cs
│ │ │ ├── TaskStatus.cs
│ │ │ └── CustomField.cs
│ │ ├── DomainEvents/
│ │ │ ├── ProjectCreatedEvent.cs
│ │ │ ├── TaskStatusChangedEvent.cs
│ │ │ └── TaskAssignedEvent.cs
│ │ ├── Interfaces/
│ │ │ ├── IProjectRepository.cs
│ │ │ └── IUnitOfWork.cs
│ │ ├── Services/
│ │ │ └── WorkflowService.cs # Domain Service
│ │ └── Exceptions/
│ │ └── DomainException.cs
│ │
│ ├── ColaFlow.Application/ # Use cases and orchestration
│ │ ├── Commands/ # CQRS Commands
│ │ │ ├── Projects/
│ │ │ │ ├── CreateProject/
│ │ │ │ │ ├── CreateProjectCommand.cs
│ │ │ │ │ ├── CreateProjectCommandHandler.cs
│ │ │ │ │ └── CreateProjectCommandValidator.cs
│ │ │ │ ├── UpdateProject/
│ │ │ │ └── DeleteProject/
│ │ │ ├── Tasks/
│ │ │ │ ├── CreateTask/
│ │ │ │ ├── UpdateTaskStatus/
│ │ │ │ └── AssignTask/
│ │ │ └── Workflows/
│ │ ├── Queries/ # CQRS Queries
│ │ │ ├── Projects/
│ │ │ │ ├── GetProjectById/
│ │ │ │ │ ├── GetProjectByIdQuery.cs
│ │ │ │ │ └── GetProjectByIdQueryHandler.cs
│ │ │ │ ├── GetProjectList/
│ │ │ │ └── GetProjectKanban/
│ │ │ ├── Tasks/
│ │ │ └── AuditLogs/
│ │ ├── DTOs/
│ │ │ ├── ProjectDto.cs
│ │ │ ├── TaskDto.cs
│ │ │ └── KanbanBoardDto.cs
│ │ ├── Mappings/
│ │ │ └── AutoMapperProfile.cs
│ │ ├── Behaviors/ # MediatR Pipeline Behaviors
│ │ │ ├── ValidationBehavior.cs
│ │ │ ├── LoggingBehavior.cs
│ │ │ └── TransactionBehavior.cs
│ │ └── Interfaces/
│ │ └── ICurrentUserService.cs
│ │
│ ├── ColaFlow.Infrastructure/ # External concerns
│ │ ├── Persistence/
│ │ │ ├── ColaFlowDbContext.cs # EF Core DbContext
│ │ │ ├── Configurations/ # Entity Configurations
│ │ │ │ ├── ProjectConfiguration.cs
│ │ │ │ └── TaskConfiguration.cs
│ │ │ ├── Repositories/
│ │ │ │ ├── ProjectRepository.cs
│ │ │ │ └── UserRepository.cs
│ │ │ ├── Migrations/
│ │ │ └── EventStore/
│ │ │ └── EventStoreRepository.cs
│ │ ├── Identity/
│ │ │ ├── IdentityService.cs
│ │ │ └── CurrentUserService.cs
│ │ ├── Services/
│ │ │ ├── DateTimeService.cs
│ │ │ └── EmailService.cs
│ │ ├── Caching/
│ │ │ └── RedisCacheService.cs
│ │ └── SignalR/
│ │ └── NotificationHub.cs
│ │
│ └── ColaFlow.API/ # Presentation layer
│ ├── Controllers/
│ │ ├── ProjectsController.cs
│ │ ├── TasksController.cs
│ │ ├── WorkflowsController.cs
│ │ └── AuditLogsController.cs
│ ├── Hubs/
│ │ └── ProjectHub.cs # SignalR Hub
│ ├── Middleware/
│ │ ├── ExceptionHandlingMiddleware.cs
│ │ └── RequestLoggingMiddleware.cs
│ ├── Filters/
│ │ ├── ValidateModelStateFilter.cs
│ │ └── ApiExceptionFilter.cs
│ ├── Program.cs # Application entry point
│ ├── appsettings.json
│ └── appsettings.Development.json
│
└── tests/
├── ColaFlow.Domain.Tests/ # Domain unit tests
├── ColaFlow.Application.Tests/ # Application unit tests
├── ColaFlow.Infrastructure.Tests/ # Infrastructure integration tests
└── ColaFlow.API.Tests/ # API integration tests
2.2 Domain Layer - DDD Tactical Patterns
2.2.1 Project Aggregate Root
namespace ColaFlow.Domain.Aggregates.ProjectAggregate
{
/// <summary>
/// Project Aggregate Root
/// Enforces consistency boundary for Project -> Epic -> Story -> Task hierarchy
/// </summary>
public class Project : AggregateRoot
{
public ProjectId Id { get; private set; }
public string Name { get; private set; }
public string Description { get; private set; }
public ProjectKey Key { get; private set; } // e.g., "COLA"
public ProjectStatus Status { get; private set; }
public UserId OwnerId { get; private set; }
private readonly List<Epic> _epics = new();
public IReadOnlyCollection<Epic> Epics => _epics.AsReadOnly();
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
// Factory method
public static Project Create(string name, string description, string key, UserId ownerId)
{
// Validation
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Project name cannot be empty");
if (key.Length > 10)
throw new DomainException("Project key cannot exceed 10 characters");
var project = new Project
{
Id = ProjectId.Create(),
Name = name,
Description = description,
Key = ProjectKey.Create(key),
Status = ProjectStatus.Active,
OwnerId = ownerId,
CreatedAt = DateTime.UtcNow
};
// Raise domain event
project.AddDomainEvent(new ProjectCreatedEvent(project.Id, project.Name, ownerId));
return project;
}
// Business methods
public void UpdateDetails(string name, string description)
{
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Project name cannot be empty");
Name = name;
Description = description;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new ProjectUpdatedEvent(Id, Name, Description));
}
public Epic CreateEpic(string name, string description, UserId createdBy)
{
var epic = Epic.Create(name, description, this.Id, createdBy);
_epics.Add(epic);
AddDomainEvent(new EpicCreatedEvent(epic.Id, epic.Name, this.Id));
return epic;
}
public void Archive()
{
if (Status == ProjectStatus.Archived)
throw new DomainException("Project is already archived");
Status = ProjectStatus.Archived;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new ProjectArchivedEvent(Id));
}
}
/// <summary>
/// Epic Entity (part of Project aggregate)
/// </summary>
public class Epic : Entity
{
public EpicId Id { get; private set; }
public string Name { get; private set; }
public string Description { get; private set; }
public ProjectId ProjectId { get; private set; }
public TaskStatus Status { get; private set; }
private readonly List<Story> _stories = new();
public IReadOnlyCollection<Story> Stories => _stories.AsReadOnly();
public DateTime CreatedAt { get; private set; }
public UserId CreatedBy { get; private set; }
public static Epic Create(string name, string description, ProjectId projectId, UserId createdBy)
{
return new Epic
{
Id = EpicId.Create(),
Name = name,
Description = description,
ProjectId = projectId,
Status = TaskStatus.ToDo,
CreatedAt = DateTime.UtcNow,
CreatedBy = createdBy
};
}
public Story CreateStory(string title, string description, TaskPriority priority, UserId createdBy)
{
var story = Story.Create(title, description, this.Id, priority, createdBy);
_stories.Add(story);
return story;
}
}
// Story and Task entities follow similar patterns...
}
2.2.2 Value Objects
namespace ColaFlow.Domain.ValueObjects
{
/// <summary>
/// ProjectId Value Object (strongly-typed ID)
/// </summary>
public sealed class ProjectId : ValueObject
{
public Guid Value { get; private set; }
private ProjectId(Guid value)
{
Value = value;
}
public static ProjectId Create() => new ProjectId(Guid.NewGuid());
public static ProjectId Create(Guid value) => new ProjectId(value);
protected override IEnumerable<object> GetAtomicValues()
{
yield return Value;
}
public override string ToString() => Value.ToString();
}
/// <summary>
/// TaskPriority Value Object (enumeration)
/// </summary>
public sealed class TaskPriority : Enumeration
{
public static readonly TaskPriority Low = new(1, "Low");
public static readonly TaskPriority Medium = new(2, "Medium");
public static readonly TaskPriority High = new(3, "High");
public static readonly TaskPriority Urgent = new(4, "Urgent");
private TaskPriority(int id, string name) : base(id, name) { }
}
/// <summary>
/// CustomField Value Object (flexible schema)
/// </summary>
public sealed class CustomField : ValueObject
{
public string Key { get; private set; }
public string Value { get; private set; }
public CustomFieldType Type { get; private set; }
public static CustomField Create(string key, string value, CustomFieldType type)
{
if (string.IsNullOrWhiteSpace(key))
throw new DomainException("Custom field key cannot be empty");
return new CustomField
{
Key = key,
Value = value,
Type = type
};
}
protected override IEnumerable<object> GetAtomicValues()
{
yield return Key;
yield return Value;
yield return Type;
}
}
}
2.2.3 Domain Events
namespace ColaFlow.Domain.DomainEvents
{
/// <summary>
/// Base Domain Event
/// </summary>
public abstract record DomainEvent
{
public Guid EventId { get; init; } = Guid.NewGuid();
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
}
/// <summary>
/// ProjectCreatedEvent
/// </summary>
public record ProjectCreatedEvent(
ProjectId ProjectId,
string ProjectName,
UserId CreatedBy
) : DomainEvent;
/// <summary>
/// TaskStatusChangedEvent (for audit trail and real-time notifications)
/// </summary>
public record TaskStatusChangedEvent(
TaskId TaskId,
TaskStatus OldStatus,
TaskStatus NewStatus,
UserId ChangedBy,
string Reason
) : DomainEvent;
/// <summary>
/// TaskAssignedEvent
/// </summary>
public record TaskAssignedEvent(
TaskId TaskId,
UserId AssigneeId,
UserId AssignedBy
) : DomainEvent;
}
2.3 Application Layer - CQRS with MediatR
2.3.1 Command Example
namespace ColaFlow.Application.Commands.Projects.CreateProject
{
// Command (request)
public sealed record CreateProjectCommand : IRequest<ProjectDto>
{
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
}
// Command Validator (FluentValidation)
public sealed class CreateProjectCommandValidator : AbstractValidator<CreateProjectCommand>
{
public CreateProjectCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Project name is required")
.MaximumLength(200).WithMessage("Project name cannot exceed 200 characters");
RuleFor(x => x.Key)
.NotEmpty().WithMessage("Project key is required")
.Matches("^[A-Z]{2,10}$").WithMessage("Project key must be 2-10 uppercase letters");
}
}
// Command Handler
public sealed class CreateProjectCommandHandler : IRequestHandler<CreateProjectCommand, ProjectDto>
{
private readonly IProjectRepository _projectRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly ICurrentUserService _currentUserService;
private readonly IMapper _mapper;
private readonly ILogger<CreateProjectCommandHandler> _logger;
public CreateProjectCommandHandler(
IProjectRepository projectRepository,
IUnitOfWork unitOfWork,
ICurrentUserService currentUserService,
IMapper mapper,
ILogger<CreateProjectCommandHandler> logger)
{
_projectRepository = projectRepository;
_unitOfWork = unitOfWork;
_currentUserService = currentUserService;
_mapper = mapper;
_logger = logger;
}
public async Task<ProjectDto> Handle(CreateProjectCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation("Creating project: {ProjectName}", request.Name);
// Check if project key already exists
var existingProject = await _projectRepository.GetByKeyAsync(request.Key, cancellationToken);
if (existingProject != null)
throw new DomainException($"Project with key '{request.Key}' already exists");
// Get current user
var currentUserId = UserId.Create(_currentUserService.UserId);
// Create aggregate
var project = Project.Create(
request.Name,
request.Description,
request.Key,
currentUserId
);
// Save to repository
await _projectRepository.AddAsync(project, cancellationToken);
await _unitOfWork.CommitAsync(cancellationToken);
_logger.LogInformation("Project created successfully: {ProjectId}", project.Id);
// Return DTO
return _mapper.Map<ProjectDto>(project);
}
}
}
2.3.2 Query Example
namespace ColaFlow.Application.Queries.Projects.GetProjectKanban
{
// Query (request)
public sealed record GetProjectKanbanQuery(Guid ProjectId) : IRequest<KanbanBoardDto>;
// Query Handler (can use Dapper for performance)
public sealed class GetProjectKanbanQueryHandler : IRequestHandler<GetProjectKanbanQuery, KanbanBoardDto>
{
private readonly ColaFlowDbContext _context;
private readonly IMapper _mapper;
public GetProjectKanbanQueryHandler(ColaFlowDbContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public async Task<KanbanBoardDto> Handle(GetProjectKanbanQuery request, CancellationToken cancellationToken)
{
// Optimized query for read side
var project = await _context.Projects
.AsNoTracking()
.Include(p => p.Epics)
.ThenInclude(e => e.Stories)
.ThenInclude(s => s.Tasks)
.FirstOrDefaultAsync(p => p.Id == ProjectId.Create(request.ProjectId), cancellationToken);
if (project == null)
throw new NotFoundException(nameof(Project), request.ProjectId);
// Transform to Kanban view model
var kanban = new KanbanBoardDto
{
ProjectId = project.Id.Value,
ProjectName = project.Name,
Columns = new List<KanbanColumnDto>
{
new() { Status = "To Do", Tasks = GetTasksByStatus(project, TaskStatus.ToDo) },
new() { Status = "In Progress", Tasks = GetTasksByStatus(project, TaskStatus.InProgress) },
new() { Status = "Review", Tasks = GetTasksByStatus(project, TaskStatus.InReview) },
new() { Status = "Done", Tasks = GetTasksByStatus(project, TaskStatus.Done) }
}
};
return kanban;
}
private List<TaskCardDto> GetTasksByStatus(Project project, TaskStatus status)
{
return project.Epics
.SelectMany(e => e.Stories)
.SelectMany(s => s.Tasks)
.Where(t => t.Status == status)
.Select(t => _mapper.Map<TaskCardDto>(t))
.ToList();
}
}
}
2.3.3 MediatR Pipeline Behaviors
namespace ColaFlow.Application.Behaviors
{
/// <summary>
/// Validation Behavior (runs before handler)
/// </summary>
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}
/// <summary>
/// Transaction Behavior (commits UnitOfWork after handler)
/// </summary>
public sealed class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IUnitOfWork _unitOfWork;
public TransactionBehavior(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Execute handler
var response = await next();
// Commit transaction and dispatch domain events
await _unitOfWork.CommitAsync(cancellationToken);
return response;
}
}
}
2.4 Infrastructure Layer - Persistence
2.4.1 EF Core DbContext
namespace ColaFlow.Infrastructure.Persistence
{
public class ColaFlowDbContext : DbContext, IUnitOfWork
{
private readonly IDomainEventDispatcher _domainEventDispatcher;
public DbSet<Project> Projects => Set<Project>();
public DbSet<User> Users => Set<User>();
public DbSet<Workflow> Workflows => Set<Workflow>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<DomainEventRecord> DomainEvents => Set<DomainEventRecord>();
public ColaFlowDbContext(
DbContextOptions<ColaFlowDbContext> options,
IDomainEventDispatcher domainEventDispatcher) : base(options)
{
_domainEventDispatcher = domainEventDispatcher;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
// Global query filters
modelBuilder.Entity<Project>().HasQueryFilter(p => !p.IsDeleted);
modelBuilder.Entity<User>().HasQueryFilter(u => !u.IsDeleted);
}
public async Task<int> CommitAsync(CancellationToken cancellationToken = default)
{
// Dispatch domain events before saving
await DispatchDomainEventsAsync(cancellationToken);
// Save changes
return await base.SaveChangesAsync(cancellationToken);
}
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
{
var domainEntities = ChangeTracker
.Entries<AggregateRoot>()
.Where(x => x.Entity.DomainEvents.Any())
.Select(x => x.Entity)
.ToList();
var domainEvents = domainEntities
.SelectMany(x => x.DomainEvents)
.ToList();
// Clear domain events
domainEntities.ForEach(entity => entity.ClearDomainEvents());
// Dispatch events
foreach (var domainEvent in domainEvents)
{
await _domainEventDispatcher.DispatchAsync(domainEvent, cancellationToken);
}
// Store events in event store
foreach (var domainEvent in domainEvents)
{
DomainEvents.Add(new DomainEventRecord
{
Id = Guid.NewGuid(),
EventType = domainEvent.GetType().Name,
EventData = JsonSerializer.Serialize(domainEvent),
OccurredOn = domainEvent.OccurredOn
});
}
}
}
}
2.4.2 Entity Configuration (Fluent API)
namespace ColaFlow.Infrastructure.Persistence.Configurations
{
public class ProjectConfiguration : IEntityTypeConfiguration<Project>
{
public void Configure(EntityTypeBuilder<Project> builder)
{
builder.ToTable("Projects");
builder.HasKey(p => p.Id);
builder.Property(p => p.Id)
.HasConversion(
id => id.Value,
value => ProjectId.Create(value))
.IsRequired();
builder.Property(p => p.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(p => p.Description)
.HasMaxLength(2000);
builder.OwnsOne(p => p.Key, keyBuilder =>
{
keyBuilder.Property(k => k.Value)
.HasColumnName("Key")
.IsRequired()
.HasMaxLength(10);
keyBuilder.HasIndex(k => k.Value).IsUnique();
});
builder.Property(p => p.Status)
.HasConversion<string>()
.IsRequired();
// Owned collection - Custom Fields (stored as JSONB in PostgreSQL)
builder.OwnsMany(p => p.CustomFields, cfBuilder =>
{
cfBuilder.ToJson("CustomFieldsJson");
});
// Navigation properties
builder.HasMany(p => p.Epics)
.WithOne()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>()
.WithMany()
.HasForeignKey(p => p.OwnerId)
.OnDelete(DeleteBehavior.Restrict);
// Indexes
builder.HasIndex(p => p.CreatedAt);
builder.HasIndex(p => p.Status);
// Soft delete
builder.Property<bool>("IsDeleted").HasDefaultValue(false);
builder.Property<DateTime?>("DeletedAt");
}
}
}
2.4.3 Repository Implementation
namespace ColaFlow.Infrastructure.Persistence.Repositories
{
public class ProjectRepository : IProjectRepository
{
private readonly ColaFlowDbContext _context;
public ProjectRepository(ColaFlowDbContext context)
{
_context = context;
}
public async Task<Project?> GetByIdAsync(ProjectId id, CancellationToken cancellationToken = default)
{
return await _context.Projects
.Include(p => p.Epics)
.ThenInclude(e => e.Stories)
.ThenInclude(s => s.Tasks)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
}
public async Task<Project?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
{
return await _context.Projects
.FirstOrDefaultAsync(p => p.Key.Value == key, cancellationToken);
}
public async Task<List<Project>> GetAllAsync(int page, int pageSize, CancellationToken cancellationToken = default)
{
return await _context.Projects
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
}
public async Task AddAsync(Project project, CancellationToken cancellationToken = default)
{
await _context.Projects.AddAsync(project, cancellationToken);
}
public void Update(Project project)
{
_context.Projects.Update(project);
}
public void Delete(Project project)
{
// Soft delete
_context.Entry(project).Property("IsDeleted").CurrentValue = true;
_context.Entry(project).Property("DeletedAt").CurrentValue = DateTime.UtcNow;
}
}
}
3. Database Design (PostgreSQL)
3.1 Database Schema
-- Projects Table
CREATE TABLE "Projects" (
"Id" UUID PRIMARY KEY,
"Name" VARCHAR(200) NOT NULL,
"Description" VARCHAR(2000),
"Key" VARCHAR(10) NOT NULL UNIQUE,
"Status" VARCHAR(50) NOT NULL,
"OwnerId" UUID NOT NULL,
"CustomFieldsJson" JSONB,
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"UpdatedAt" TIMESTAMP,
"IsDeleted" BOOLEAN NOT NULL DEFAULT FALSE,
"DeletedAt" TIMESTAMP,
CONSTRAINT "FK_Projects_Users" FOREIGN KEY ("OwnerId") REFERENCES "Users"("Id")
);
CREATE INDEX "IX_Projects_Key" ON "Projects"("Key");
CREATE INDEX "IX_Projects_Status" ON "Projects"("Status");
CREATE INDEX "IX_Projects_CreatedAt" ON "Projects"("CreatedAt");
CREATE INDEX "IX_Projects_OwnerId" ON "Projects"("OwnerId");
-- Epics Table
CREATE TABLE "Epics" (
"Id" UUID PRIMARY KEY,
"Name" VARCHAR(200) NOT NULL,
"Description" VARCHAR(2000),
"ProjectId" UUID NOT NULL,
"Status" VARCHAR(50) NOT NULL,
"Priority" VARCHAR(50) NOT NULL DEFAULT 'Medium',
"CreatedBy" UUID NOT NULL,
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"UpdatedAt" TIMESTAMP,
"IsDeleted" BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT "FK_Epics_Projects" FOREIGN KEY ("ProjectId") REFERENCES "Projects"("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Epics_Users" FOREIGN KEY ("CreatedBy") REFERENCES "Users"("Id")
);
CREATE INDEX "IX_Epics_ProjectId" ON "Epics"("ProjectId");
CREATE INDEX "IX_Epics_Status" ON "Epics"("Status");
-- Stories Table
CREATE TABLE "Stories" (
"Id" UUID PRIMARY KEY,
"Title" VARCHAR(200) NOT NULL,
"Description" TEXT,
"EpicId" UUID NOT NULL,
"Status" VARCHAR(50) NOT NULL,
"Priority" VARCHAR(50) NOT NULL DEFAULT 'Medium',
"EstimatedHours" DECIMAL(10,2),
"ActualHours" DECIMAL(10,2),
"AssigneeId" UUID,
"CreatedBy" UUID NOT NULL,
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"UpdatedAt" TIMESTAMP,
"IsDeleted" BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT "FK_Stories_Epics" FOREIGN KEY ("EpicId") REFERENCES "Epics"("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Stories_AssigneeId" FOREIGN KEY ("AssigneeId") REFERENCES "Users"("Id"),
CONSTRAINT "FK_Stories_CreatedBy" FOREIGN KEY ("CreatedBy") REFERENCES "Users"("Id")
);
CREATE INDEX "IX_Stories_EpicId" ON "Stories"("EpicId");
CREATE INDEX "IX_Stories_Status" ON "Stories"("Status");
CREATE INDEX "IX_Stories_AssigneeId" ON "Stories"("AssigneeId");
-- Tasks Table
CREATE TABLE "Tasks" (
"Id" UUID PRIMARY KEY,
"Title" VARCHAR(200) NOT NULL,
"Description" TEXT,
"StoryId" UUID NOT NULL,
"Status" VARCHAR(50) NOT NULL,
"Priority" VARCHAR(50) NOT NULL DEFAULT 'Medium',
"EstimatedHours" DECIMAL(10,2),
"ActualHours" DECIMAL(10,2),
"AssigneeId" UUID,
"CustomFieldsJson" JSONB,
"CreatedBy" UUID NOT NULL,
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"UpdatedAt" TIMESTAMP,
"IsDeleted" BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT "FK_Tasks_Stories" FOREIGN KEY ("StoryId") REFERENCES "Stories"("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Tasks_AssigneeId" FOREIGN KEY ("AssigneeId") REFERENCES "Users"("Id"),
CONSTRAINT "FK_Tasks_CreatedBy" FOREIGN KEY ("CreatedBy") REFERENCES "Users"("Id")
);
CREATE INDEX "IX_Tasks_StoryId" ON "Tasks"("StoryId");
CREATE INDEX "IX_Tasks_Status" ON "Tasks"("Status");
CREATE INDEX "IX_Tasks_AssigneeId" ON "Tasks"("AssigneeId");
CREATE INDEX "IX_Tasks_Priority" ON "Tasks"("Priority");
-- Users Table
CREATE TABLE "Users" (
"Id" UUID PRIMARY KEY,
"Email" VARCHAR(255) NOT NULL UNIQUE,
"FirstName" VARCHAR(100) NOT NULL,
"LastName" VARCHAR(100) NOT NULL,
"PasswordHash" VARCHAR(500) NOT NULL,
"Role" VARCHAR(50) NOT NULL DEFAULT 'User',
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"UpdatedAt" TIMESTAMP,
"IsDeleted" BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX "IX_Users_Email" ON "Users"("Email");
-- Workflows Table
CREATE TABLE "Workflows" (
"Id" UUID PRIMARY KEY,
"Name" VARCHAR(200) NOT NULL,
"ProjectId" UUID NOT NULL,
"IsDefault" BOOLEAN NOT NULL DEFAULT FALSE,
"StatusesJson" JSONB NOT NULL, -- Array of statuses with transitions
"CreatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"UpdatedAt" TIMESTAMP,
CONSTRAINT "FK_Workflows_Projects" FOREIGN KEY ("ProjectId") REFERENCES "Projects"("Id") ON DELETE CASCADE
);
CREATE INDEX "IX_Workflows_ProjectId" ON "Workflows"("ProjectId");
-- Audit Logs Table (Event Store)
CREATE TABLE "AuditLogs" (
"Id" BIGSERIAL PRIMARY KEY,
"EntityType" VARCHAR(100) NOT NULL,
"EntityId" UUID NOT NULL,
"Action" VARCHAR(100) NOT NULL,
"Changes" JSONB NOT NULL,
"UserId" UUID NOT NULL,
"Timestamp" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"IpAddress" VARCHAR(50),
CONSTRAINT "FK_AuditLogs_Users" FOREIGN KEY ("UserId") REFERENCES "Users"("Id")
);
CREATE INDEX "IX_AuditLogs_EntityType_EntityId" ON "AuditLogs"("EntityType", "EntityId");
CREATE INDEX "IX_AuditLogs_Timestamp" ON "AuditLogs"("Timestamp" DESC);
CREATE INDEX "IX_AuditLogs_UserId" ON "AuditLogs"("UserId");
-- Domain Events Table (Event Store)
CREATE TABLE "DomainEvents" (
"Id" BIGSERIAL PRIMARY KEY,
"EventType" VARCHAR(200) NOT NULL,
"AggregateId" UUID NOT NULL,
"EventData" JSONB NOT NULL,
"OccurredOn" TIMESTAMP NOT NULL,
"ProcessedOn" TIMESTAMP
);
CREATE INDEX "IX_DomainEvents_AggregateId" ON "DomainEvents"("AggregateId");
CREATE INDEX "IX_DomainEvents_EventType" ON "DomainEvents"("EventType");
CREATE INDEX "IX_DomainEvents_OccurredOn" ON "DomainEvents"("OccurredOn" DESC);
3.2 Database Strategy Decisions
Why PostgreSQL?
- ACID Transactions: Essential for DDD aggregate consistency
- JSONB Support: Flexible schema for custom fields without schema migrations
- Recursive Queries: Excellent for hierarchical data (Projects → Epics → Stories → Tasks)
- Full-Text Search: Built-in search capabilities
- Event Sourcing: Perfect for audit logs and event store
- Performance: Fast with proper indexing
- Open Source: No licensing costs
JSONB Usage
- Custom Fields: Store user-defined fields without schema changes
- Workflow Configuration: Store workflow states and transitions
- Event Data: Store domain events as JSON documents
- Metadata: Store flexible metadata on entities
4. Frontend Architecture (Next.js 15)
4.1 Project Structure
colaflow-web/
├── app/ # Next.js 15 App Router
│ ├── (auth)/ # Auth route group
│ │ ├── login/
│ │ │ └── page.tsx
│ │ ├── register/
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── (dashboard)/ # Dashboard route group
│ │ ├── projects/
│ │ │ ├── page.tsx # Project list
│ │ │ ├── [id]/
│ │ │ │ ├── page.tsx # Project detail
│ │ │ │ ├── board/ # Kanban board
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── tasks/
│ │ │ │ │ ├── page.tsx # Task list
│ │ │ │ │ └── [taskId]/
│ │ │ │ │ └── page.tsx # Task detail
│ │ │ │ ├── workflows/
│ │ │ │ │ └── page.tsx
│ │ │ │ └── audit/
│ │ │ │ └── page.tsx # Audit logs
│ │ │ └── create/
│ │ │ └── page.tsx
│ │ ├── dashboard/
│ │ │ └── page.tsx # Main dashboard
│ │ └── layout.tsx # Dashboard layout
│ ├── api/ # API routes (BFF pattern)
│ │ └── auth/
│ │ └── [...nextauth]/
│ │ └── route.ts
│ ├── layout.tsx # Root layout
│ └── page.tsx # Home page
│
├── components/ # Shared components
│ ├── ui/ # shadcn/ui components
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ ├── input.tsx
│ │ └── ...
│ ├── layouts/
│ │ ├── Header.tsx
│ │ ├── Sidebar.tsx
│ │ └── Footer.tsx
│ ├── features/ # Feature-specific components
│ │ ├── projects/
│ │ │ ├── ProjectCard.tsx
│ │ │ ├── ProjectForm.tsx
│ │ │ └── ProjectList.tsx
│ │ ├── kanban/
│ │ │ ├── KanbanBoard.tsx
│ │ │ ├── KanbanColumn.tsx
│ │ │ └── TaskCard.tsx
│ │ └── audit/
│ │ ├── AuditLogList.tsx
│ │ └── AuditLogDetail.tsx
│ └── common/
│ ├── Loading.tsx
│ ├── ErrorBoundary.tsx
│ └── NotFound.tsx
│
├── lib/ # Utilities and configurations
│ ├── api/ # API client
│ │ ├── client.ts # Axios instance
│ │ ├── projects.ts # Project API calls
│ │ ├── tasks.ts # Task API calls
│ │ └── users.ts # User API calls
│ ├── hooks/ # Custom React hooks
│ │ ├── useProjects.ts
│ │ ├── useTasks.ts
│ │ └── useAuth.ts
│ ├── store/ # Zustand stores
│ │ ├── authStore.ts
│ │ ├── uiStore.ts
│ │ └── notificationStore.ts
│ ├── utils/
│ │ ├── cn.ts # Tailwind merge utility
│ │ ├── format.ts # Formatting utilities
│ │ └── validation.ts # Form validation
│ └── signalr/
│ └── hubConnection.ts # SignalR connection setup
│
├── types/ # TypeScript types
│ ├── api.ts # API response types
│ ├── models.ts # Domain models
│ └── components.ts # Component prop types
│
├── styles/
│ └── globals.css # Global styles with Tailwind
│
├── public/
│ ├── images/
│ └── icons/
│
├── .env.local # Environment variables
├── next.config.js
├── tailwind.config.ts
├── tsconfig.json
└── package.json
4.2 State Management Strategy
4.2.1 TanStack Query (Server State)
// lib/hooks/useProjects.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api/projects';
import type { Project, CreateProjectDto } from '@/types/models';
export function useProjects() {
return useQuery<Project[]>({
queryKey: ['projects'],
queryFn: projectsApi.getAll,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useProject(id: string) {
return useQuery<Project>({
queryKey: ['projects', id],
queryFn: () => projectsApi.getById(id),
enabled: !!id,
});
}
export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProjectDto) => projectsApi.create(data),
onSuccess: (newProject) => {
// Invalidate and refetch projects list
queryClient.invalidateQueries({ queryKey: ['projects'] });
// Optimistically update cache
queryClient.setQueryData<Project[]>(['projects'], (old) =>
old ? [...old, newProject] : [newProject]
);
},
});
}
export function useUpdateProject(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Project>) => projectsApi.update(id, data),
onMutate: async (updatedData) => {
// Optimistic update
await queryClient.cancelQueries({ queryKey: ['projects', id] });
const previousProject = queryClient.getQueryData<Project>(['projects', id]);
queryClient.setQueryData<Project>(['projects', id], (old) => ({
...old!,
...updatedData,
}));
return { previousProject };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousProject) {
queryClient.setQueryData(['projects', id], context.previousProject);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
},
});
}
4.2.2 Zustand (Client/UI State)
// lib/store/uiStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface UIState {
// Sidebar
sidebarOpen: boolean;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
// Theme
theme: 'light' | 'dark' | 'system';
setTheme: (theme: 'light' | 'dark' | 'system') => void;
// Modals
openModal: string | null;
setOpenModal: (modal: string | null) => void;
// Notifications
notifications: Notification[];
addNotification: (notification: Omit<Notification, 'id'>) => void;
removeNotification: (id: string) => void;
}
export const useUIStore = create<UIState>()(
devtools(
persist(
(set) => ({
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
theme: 'system',
setTheme: (theme) => set({ theme }),
openModal: null,
setOpenModal: (modal) => set({ openModal: modal }),
notifications: [],
addNotification: (notification) =>
set((state) => ({
notifications: [
...state.notifications,
{ ...notification, id: crypto.randomUUID() },
],
})),
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
}),
{
name: 'colaflow-ui-storage',
partialize: (state) => ({ sidebarOpen: state.sidebarOpen, theme: state.theme }),
}
)
)
);
4.2.3 SignalR Real-time Updates
// lib/signalr/hubConnection.ts
import * as signalR from '@microsoft/signalr';
class SignalRService {
private connection: signalR.HubConnection | null = null;
async connect(token: string) {
this.connection = new signalR.HubConnectionBuilder()
.withUrl(`${process.env.NEXT_PUBLIC_API_URL}/hubs/project`, {
accessTokenFactory: () => token,
})
.withAutomaticReconnect()
.build();
await this.connection.start();
console.log('SignalR Connected');
}
onTaskUpdated(callback: (task: Task) => void) {
this.connection?.on('TaskUpdated', callback);
}
onTaskCreated(callback: (task: Task) => void) {
this.connection?.on('TaskCreated', callback);
}
offTaskUpdated() {
this.connection?.off('TaskUpdated');
}
async disconnect() {
await this.connection?.stop();
}
}
export const signalRService = new SignalRService();
// Usage in a component
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { signalRService } from '@/lib/signalr/hubConnection';
export function useRealtimeUpdates(projectId: string) {
const queryClient = useQueryClient();
useEffect(() => {
signalRService.onTaskUpdated((task) => {
// Update cache with new task data
queryClient.setQueryData(['tasks', task.id], task);
queryClient.invalidateQueries({ queryKey: ['projects', projectId, 'kanban'] });
});
return () => {
signalRService.offTaskUpdated();
};
}, [projectId, queryClient]);
}
4.3 Key Components
4.3.1 Kanban Board Component
// components/features/kanban/KanbanBoard.tsx
'use client';
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from '@dnd-kit/core';
import { useState } from 'react';
import { KanbanColumn } from './KanbanColumn';
import { TaskCard } from './TaskCard';
import { useKanbanBoard, useUpdateTaskStatus } from '@/lib/hooks/useKanban';
import { useRealtimeUpdates } from '@/lib/hooks/useRealtimeUpdates';
import type { Task } from '@/types/models';
interface KanbanBoardProps {
projectId: string;
}
export function KanbanBoard({ projectId }: KanbanBoardProps) {
const { data: kanban, isLoading } = useKanbanBoard(projectId);
const updateStatus = useUpdateTaskStatus();
const [activeTask, setActiveTask] = useState<Task | null>(null);
// Real-time updates
useRealtimeUpdates(projectId);
const handleDragStart = (event: DragStartEvent) => {
const task = event.active.data.current as Task;
setActiveTask(task);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const taskId = active.id as string;
const newStatus = over.id as string;
// Optimistic update
updateStatus.mutate({ taskId, newStatus });
setActiveTask(null);
};
if (isLoading) return <div>Loading...</div>;
return (
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto p-4">
{kanban?.columns.map((column) => (
<KanbanColumn
key={column.status}
column={column}
tasks={column.tasks}
/>
))}
</div>
<DragOverlay>
{activeTask ? <TaskCard task={activeTask} isDragging /> : null}
</DragOverlay>
</DndContext>
);
}
5. API Design (REST + OpenAPI)
5.1 RESTful API Endpoints
# Projects
GET /api/v1/projects # List all projects
POST /api/v1/projects # Create project
GET /api/v1/projects/{id} # Get project by ID
PUT /api/v1/projects/{id} # Update project
DELETE /api/v1/projects/{id} # Delete project
GET /api/v1/projects/{id}/kanban # Get Kanban board
# Epics
GET /api/v1/projects/{projectId}/epics # List epics
POST /api/v1/projects/{projectId}/epics # Create epic
GET /api/v1/projects/{projectId}/epics/{id} # Get epic
PUT /api/v1/projects/{projectId}/epics/{id} # Update epic
DELETE /api/v1/projects/{projectId}/epics/{id} # Delete epic
# Stories
GET /api/v1/epics/{epicId}/stories # List stories
POST /api/v1/epics/{epicId}/stories # Create story
GET /api/v1/epics/{epicId}/stories/{id} # Get story
PUT /api/v1/epics/{epicId}/stories/{id} # Update story
DELETE /api/v1/epics/{epicId}/stories/{id} # Delete story
# Tasks
GET /api/v1/stories/{storyId}/tasks # List tasks
POST /api/v1/stories/{storyId}/tasks # Create task
GET /api/v1/tasks/{id} # Get task by ID
PUT /api/v1/tasks/{id} # Update task
PATCH /api/v1/tasks/{id}/status # Update task status
DELETE /api/v1/tasks/{id} # Delete task
POST /api/v1/tasks/{id}/assign # Assign task to user
# Workflows
GET /api/v1/projects/{projectId}/workflows # List workflows
POST /api/v1/projects/{projectId}/workflows # Create workflow
GET /api/v1/workflows/{id} # Get workflow
PUT /api/v1/workflows/{id} # Update workflow
DELETE /api/v1/workflows/{id} # Delete workflow
# Audit Logs
GET /api/v1/audit-logs # List all audit logs
GET /api/v1/audit-logs/{entityType}/{entityId} # Get entity audit logs
POST /api/v1/audit-logs/{id}/rollback # Rollback changes
# Users
GET /api/v1/users # List users
GET /api/v1/users/{id} # Get user
POST /api/v1/users # Create user (admin)
PUT /api/v1/users/{id} # Update user
# Authentication
POST /api/v1/auth/login # Login
POST /api/v1/auth/register # Register
POST /api/v1/auth/refresh # Refresh token
POST /api/v1/auth/logout # Logout
5.2 Controller Example
namespace ColaFlow.API.Controllers
{
[ApiController]
[Route("api/v1/[controller]")]
[Authorize]
public class ProjectsController : ControllerBase
{
private readonly IMediator _mediator;
public ProjectsController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// Get all projects
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(List<ProjectDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetProjects(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var query = new GetProjectsQuery(page, pageSize);
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
/// Get project by ID
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProject(
Guid id,
CancellationToken cancellationToken = default)
{
var query = new GetProjectByIdQuery(id);
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
/// Create a new project
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateProject(
[FromBody] CreateProjectCommand command,
CancellationToken cancellationToken = default)
{
var result = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(nameof(GetProject), new { id = result.Id }, result);
}
/// <summary>
/// Update project
/// </summary>
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateProject(
Guid id,
[FromBody] UpdateProjectCommand command,
CancellationToken cancellationToken = default)
{
command = command with { Id = id };
var result = await _mediator.Send(command, cancellationToken);
return Ok(result);
}
/// <summary>
/// Delete project
/// </summary>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteProject(
Guid id,
CancellationToken cancellationToken = default)
{
var command = new DeleteProjectCommand(id);
await _mediator.Send(command, cancellationToken);
return NoContent();
}
/// <summary>
/// Get Kanban board for project
/// </summary>
[HttpGet("{id:guid}/kanban")]
[ProducesResponseType(typeof(KanbanBoardDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetKanbanBoard(
Guid id,
CancellationToken cancellationToken = default)
{
var query = new GetProjectKanbanQuery(id);
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
}
}
5.3 OpenAPI Configuration
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add OpenAPI
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, context, cancellationToken) =>
{
document.Info = new()
{
Title = "ColaFlow API",
Version = "v1",
Description = "AI-powered project management system API",
Contact = new() { Name = "ColaFlow Team", Email = "api@colaflow.com" }
};
return Task.CompletedTask;
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference(); // Modern Swagger UI alternative
}
6. Security Architecture
6.1 Authentication & Authorization
JWT Token-based Authentication
// Infrastructure/Identity/JwtTokenService.cs
public class JwtTokenService : ITokenService
{
private readonly JwtSettings _jwtSettings;
public string GenerateAccessToken(User user)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, $"{user.FirstName} {user.LastName}"),
new Claim(ClaimTypes.Role, user.Role.ToString())
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
expires: DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Authorization Policies
// Program.cs
builder.Services.AddAuthorization(options =>
{
// Role-based policies
options.AddPolicy("Admin", policy => policy.RequireRole("Admin"));
options.AddPolicy("ProjectManager", policy => policy.RequireRole("Admin", "ProjectManager"));
// Resource-based policies
options.AddPolicy("ProjectOwner", policy =>
policy.Requirements.Add(new ProjectOwnerRequirement()));
options.AddPolicy("TaskAssignee", policy =>
policy.Requirements.Add(new TaskAssigneeRequirement()));
});
6.2 Data Security
- Encryption at Rest: PostgreSQL encryption
- Encryption in Transit: HTTPS/TLS 1.3
- Password Hashing: BCrypt with salt
- SQL Injection Protection: Parameterized queries (EF Core)
- XSS Protection: Input sanitization, CSP headers
- CSRF Protection: Anti-forgery tokens
7. Deployment Architecture
7.1 Containerization (Docker)
# Dockerfile for Backend
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["src/ColaFlow.API/ColaFlow.API.csproj", "ColaFlow.API/"]
COPY ["src/ColaFlow.Application/ColaFlow.Application.csproj", "ColaFlow.Application/"]
COPY ["src/ColaFlow.Domain/ColaFlow.Domain.csproj", "ColaFlow.Domain/"]
COPY ["src/ColaFlow.Infrastructure/ColaFlow.Infrastructure.csproj", "ColaFlow.Infrastructure/"]
RUN dotnet restore "ColaFlow.API/ColaFlow.API.csproj"
COPY src/ .
WORKDIR "/src/ColaFlow.API"
RUN dotnet build "ColaFlow.API.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "ColaFlow.API.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ColaFlow.API.dll"]
# Dockerfile for Frontend
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
7.2 Docker Compose (Development)
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: colaflow
POSTGRES_USER: colaflow
POSTGRES_PASSWORD: colaflow_dev_password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U colaflow"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
backend:
build:
context: .
dockerfile: Dockerfile.backend
ports:
- "5000:80"
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__DefaultConnection: Host=postgres;Database=colaflow;Username=colaflow;Password=colaflow_dev_password
ConnectionStrings__Redis: redis:6379
JwtSettings__SecretKey: your-secret-key-here-min-32-chars
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
frontend:
build:
context: ./colaflow-web
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
NEXT_PUBLIC_API_URL: http://backend:80
depends_on:
- backend
volumes:
postgres_data:
redis_data:
8. Testing Strategy
8.1 Test Pyramid
Unit Tests (80%):
- Domain logic (aggregates, entities, value objects)
- Application services (commands, queries, handlers)
- Utilities and helpers
Integration Tests (15%):
- API endpoints
- Database operations
- External service integrations
End-to-End Tests (5%):
- Critical user flows
- Kanban drag-and-drop
- Authentication flows
8.2 Example Tests
// Domain.Tests/Aggregates/ProjectTests.cs
public class ProjectTests
{
[Fact]
public void Create_ValidData_ShouldCreateProject()
{
// Arrange
var name = "Test Project";
var description = "Test Description";
var key = "TEST";
var ownerId = UserId.Create(Guid.NewGuid());
// Act
var project = Project.Create(name, description, key, ownerId);
// Assert
project.Should().NotBeNull();
project.Name.Should().Be(name);
project.Key.Value.Should().Be(key);
project.Status.Should().Be(ProjectStatus.Active);
project.DomainEvents.Should().ContainSingle(e => e is ProjectCreatedEvent);
}
[Fact]
public void Create_EmptyName_ShouldThrowException()
{
// Arrange
var name = "";
var key = "TEST";
var ownerId = UserId.Create(Guid.NewGuid());
// Act
Action act = () => Project.Create(name, "", key, ownerId);
// Assert
act.Should().Throw<DomainException>()
.WithMessage("Project name cannot be empty");
}
}
9. Performance Considerations
9.1 Caching Strategy
-
Redis Caching:
- User sessions (30 min)
- Project metadata (5 min)
- Kanban board data (2 min)
-
EF Core Query Optimization:
.AsNoTracking()for read-only queries- Explicit loading with
.Include() - Pagination for large datasets
-
SignalR Backplane:
- Redis for scaling SignalR across multiple servers
9.2 Database Indexing
- Index all foreign keys
- Index frequently queried columns (Status, CreatedAt, AssigneeId)
- JSONB GIN indexes for custom field queries
10. Risk Mitigation
| Risk | Impact | Mitigation |
|---|---|---|
| Complex DDD learning curve | High | Use established templates (Ardalis, EquinoxProject), pair programming |
| EF Core performance issues | Medium | Use Dapper for complex queries, implement caching |
| PostgreSQL migration challenges | Medium | Careful schema design, automated migration testing |
| Real-time performance (SignalR) | Medium | Redis backplane, connection pooling, load testing |
| Frontend state management complexity | Low | Clear separation (TanStack Query + Zustand), documentation |
11. Success Criteria
M1 Completion Criteria:
✅ Complete project hierarchy (Project → Epic → Story → Task → Subtask) ✅ Functional Kanban board with drag-and-drop ✅ Workflow system with customization ✅ Audit log and rollback capability ✅ All M1 stories complete and tested (80%+ coverage) ✅ API documented with OpenAPI ✅ Frontend responsive and accessible ✅ Real-time updates via SignalR ✅ Docker Compose development environment ✅ CI/CD pipeline ready
12. Next Steps
Immediate Actions:
-
Setup Development Environment:
- Initialize .NET 9 solution with Clean Architecture template
- Setup PostgreSQL and Redis via Docker Compose
- Initialize Next.js 15 project with App Router
- Configure shadcn/ui and Tailwind CSS
-
Sprint 1 Planning:
- Review this architecture document with team
- Create initial database schema
- Setup CI/CD pipeline (GitHub Actions)
- Begin Project aggregate implementation
-
Technical Proof of Concepts:
- DDD + CQRS + Event Sourcing with MediatR
- EF Core with PostgreSQL JSONB
- SignalR real-time updates
- Next.js + TanStack Query + Zustand integration
Appendix A: Technology Versions
| Technology | Version | Notes |
|---|---|---|
| .NET | 9.0 | Released November 2024 |
| C# | 13.0 | With .NET 9 |
| Entity Framework Core | 9.0 | Native AOT support |
| PostgreSQL | 16+ | Latest stable |
| Redis | 7+ | Latest stable |
| Node.js | 20 LTS | For Next.js |
| React | 19 | Latest |
| Next.js | 15 | App Router |
| TypeScript | 5.x | Latest |
| Tailwind CSS | 3.x | Latest |
| shadcn/ui | Latest | Component library |
Appendix B: References
- Microsoft DDD Microservices
- Clean Architecture by Uncle Bob
- Ardalis CleanArchitecture Template
- Next.js 15 Documentation
- PostgreSQL JSONB Documentation
- SignalR Documentation
Document Status: ✅ Complete - Ready for Implementation Next Review: After Sprint 1 completion Owner: Architecture Team Last Updated: 2025-11-02