Initial commit: GermanApp with Clean Architecture

- Domain layer: Lesson entity, GermanWord value object, repository interfaces
- Application layer: CQRS commands, DTOs with mapping
- Infrastructure layer: EF Core with SQLite, LessonRepository
- Presentation layer: Minimal API endpoints for lessons CRUD

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Lasse Rune Hansen 2026-05-31 18:14:51 +02:00
commit d90e4792d6
15 changed files with 788 additions and 0 deletions

View file

@ -0,0 +1,60 @@
using GermanApp.Domain.Entities;
namespace GermanApp.Application.DTOs;
/// <summary>
/// Data Transfer Object for Lesson - used for API responses.
/// This is a read-only representation of a Lesson entity.
/// </summary>
public record LessonDto(
int Id,
string Title,
string Description,
int Level,
string LevelDescription,
DateTime CreatedAt,
DateTime? UpdatedAt);
/// <summary>
/// Data Transfer Object for creating a new Lesson.
/// </summary>
public record CreateLessonDto(
string Title,
string Description,
int Level);
/// <summary>
/// Data Transfer Object for updating an existing Lesson.
/// </summary>
public record UpdateLessonDto(
string Title,
string Description,
int Level);
/// <summary>
/// Extension methods for mapping between Lesson entity and DTOs.
/// </summary>
public static class LessonDtoExtensions
{
public static LessonDto ToDto(this Lesson lesson) => new(
lesson.Id,
lesson.Title,
lesson.Description,
lesson.Level,
GetLevelDescription(lesson.Level),
lesson.CreatedAt,
lesson.UpdatedAt);
private static string GetLevelDescription(int level) => level switch
{
1 => "A1 (Beginner)",
2 => "A2 (Elementary)",
3 => "B1 (Intermediate)",
4 => "B2 (Upper Intermediate)",
5 => "C1 (Advanced)",
_ => "Unknown"
};
public static Lesson ToEntity(this CreateLessonDto dto) =>
Lesson.Create(dto.Title, dto.Description, dto.Level);
}

View file

@ -0,0 +1,65 @@
using GermanApp.Application.DTOs;
using GermanApp.Domain.Entities;
using GermanApp.Domain.Exceptions;
using GermanApp.Domain.Interfaces;
namespace GermanApp.Application.UseCases.Commands;
/// <summary>
/// Command for creating a new lesson.
/// This follows the CQRS pattern - commands represent write operations.
/// </summary>
public record CreateLessonCommand(CreateLessonDto LessonData) : ICommand<LessonDto>;
/// <summary>
/// Handler for the CreateLessonCommand.
/// </summary>
public class CreateLessonCommandHandler : ICommandHandler<CreateLessonCommand, LessonDto>
{
private readonly ILessonRepository _lessonRepository;
public CreateLessonCommandHandler(ILessonRepository lessonRepository)
{
_lessonRepository = lessonRepository;
}
public async Task<LessonDto> Handle(CreateLessonCommand command, CancellationToken cancellationToken)
{
// Convert DTO to domain entity
var lesson = command.LessonData.ToEntity();
// Validate business rules (could be extracted to a validator)
if (lesson.Level < 1 || lesson.Level > 5)
{
throw new ValidationException("Level must be between 1 and 5");
}
// Add to repository
var createdLesson = await _lessonRepository.AddAsync(lesson, cancellationToken);
// Return DTO representation
return createdLesson.ToDto();
}
}
/// <summary>
/// Generic command interface.
/// </summary>
/// <typeparam name="TResult">The result type</typeparam>
public interface ICommand<out TResult>
{
}
/// <summary>
/// Generic command handler interface.
/// </summary>
/// <typeparam name="TCommand">The command type</typeparam>
/// <typeparam name="TResult">The result type</typeparam>
public interface ICommandHandler<in TCommand, TResult>
where TCommand : ICommand<TResult>
{
Task<TResult> Handle(TCommand command, CancellationToken cancellationToken);
}
// Note: For MediatR integration, you would implement IRequestHandler<TCommand, TResult>
// instead of the custom interfaces above.

View file

@ -0,0 +1,58 @@
namespace GermanApp.Domain.Entities;
/// <summary>
/// Represents a German language lesson in the system.
/// </summary>
public class Lesson
{
// Private setter for domain behavior, but internal for EF Core
public int Id { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Description { get; private set; } = string.Empty;
public int Level { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
// Navigation properties would go here in EF Core
// public ICollection<VocabularyWord> Words { get; private set; }
/// <summary>
/// Constructor for EF Core deserialization.
/// </summary>
private Lesson() { }
/// <summary>
/// Factory method to create a new lesson.
/// </summary>
public static Lesson Create(string title, string description, int level)
{
return new Lesson
{
Title = title,
Description = description,
Level = level,
CreatedAt = DateTime.UtcNow
};
}
/// <summary>
/// Updates the lesson details.
/// </summary>
public void Update(string title, string description, int level)
{
Title = title;
Description = description;
Level = level;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Domain behavior: Check if lesson is at beginner level.
/// </summary>
public bool IsBeginnerLevel() => Level <= 2;
/// <summary>
/// Domain behavior: Check if lesson is at advanced level.
/// </summary>
public bool IsAdvancedLevel() => Level >= 4;
}

View file

@ -0,0 +1,56 @@
using System;
namespace GermanApp.Domain.Exceptions;
/// <summary>
/// Base exception for domain layer errors.
/// All domain-specific exceptions should inherit from this.
/// </summary>
public class DomainException : Exception
{
public DomainException(string message) : base(message)
{
}
public DomainException(string message, Exception innerException) : base(message, innerException)
{
}
}
/// <summary>
/// Exception thrown when a business rule validation fails.
/// </summary>
public class ValidationException : DomainException
{
public ValidationException(string message) : base(message)
{
}
public ValidationException(string message, Exception innerException) : base(message, innerException)
{
}
}
/// <summary>
/// Exception thrown when an entity is not found.
/// </summary>
public class NotFoundException : DomainException
{
public NotFoundException(string entityName, int id) : base($"{entityName} with id {id} not found")
{
}
public NotFoundException(string message) : base(message)
{
}
}
/// <summary>
/// Exception thrown when attempting to perform an invalid operation.
/// </summary>
public class InvalidOperationException : DomainException
{
public InvalidOperationException(string message) : base(message)
{
}
}

View file

@ -0,0 +1,63 @@
using GermanApp.Domain.Entities;
namespace GermanApp.Domain.Interfaces;
/// <summary>
/// Generic repository interface for domain entities.
/// All repositories should implement this interface.
/// </summary>
/// <typeparam name="TEntity">The entity type</typeparam>
/// <typeparam name="TId">The identifier type (usually int or Guid)</typeparam>
public interface IRepository<TEntity, TId> where TEntity : class
{
/// <summary>
/// Gets an entity by its identifier.
/// </summary>
Task<TEntity?> GetByIdAsync(TId id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all entities.
/// </summary>
Task<IReadOnlyList<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new entity.
/// </summary>
Task<TEntity> AddAsync(TEntity entity, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing entity.
/// </summary>
Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an entity.
/// </summary>
Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if an entity with the given identifier exists.
/// </summary>
Task<bool> ExistsAsync(TId id, CancellationToken cancellationToken = default);
}
/// <summary>
/// Repository interface for Lesson entities.
/// </summary>
public interface ILessonRepository : IRepository<Lesson, int>
{
/// <summary>
/// Gets lessons by level.
/// </summary>
Task<IReadOnlyList<Lesson>> GetByLevelAsync(int level, CancellationToken cancellationToken = default);
/// <summary>
/// Gets beginner-level lessons.
/// </summary>
Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets advanced-level lessons.
/// </summary>
Task<IReadOnlyList<Lesson>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,50 @@
using GermanApp.Domain.Exceptions;
namespace GermanApp.Domain.ValueObjects;
/// <summary>
/// Represents a German word with its translation and metadata.
/// This is a value object - it has no identity and is defined by its attributes.
/// </summary>
public record GermanWord
{
public string Value { get; }
public string Translation { get; }
public string? Article { get; } // der, die, das, or null for verbs/adjectives
public string PartOfSpeech { get; }
public GermanWord(string value, string translation, string? article, string partOfSpeech)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainException("German word cannot be empty");
if (string.IsNullOrWhiteSpace(translation))
throw new DomainException("Translation cannot be empty");
if (string.IsNullOrWhiteSpace(partOfSpeech))
throw new DomainException("Part of speech is required");
if (article != null && !IsValidArticle(article))
throw new DomainException("Invalid article. Must be 'der', 'die', or 'das'");
Value = value.Trim();
Translation = translation.Trim();
Article = article?.Trim();
PartOfSpeech = partOfSpeech.Trim();
}
private static bool IsValidArticle(string article) =>
article.Equals("der", StringComparison.OrdinalIgnoreCase) ||
article.Equals("die", StringComparison.OrdinalIgnoreCase) ||
article.Equals("das", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Check if this word has a definite article.
/// </summary>
public bool HasArticle() => Article != null;
/// <summary>
/// Get the word with its article (if applicable).
/// </summary>
public string GetWithArticle() => Article != null ? $"{Article} {Value}" : Value;
}

View file

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.16" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

6
GermanApp/GermanApp.http Normal file
View file

@ -0,0 +1,6 @@
@GermanApp_HostAddress = http://localhost:5235
GET {{GermanApp_HostAddress}}/weatherforecast/
Accept: application/json
###

View file

@ -0,0 +1,54 @@
using GermanApp.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace GermanApp.Infrastructure.Data.DbContext;
/// <summary>
/// Entity Framework Core database context.
/// This is part of the Infrastructure layer.
/// </summary>
public class AppDbContext : Microsoft.EntityFrameworkCore.DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
// DbSets for domain entities
public DbSet<Lesson> Lessons { get; set; } = null!;
// Note: Value objects are not stored directly as entities.
// They are owned by entities and stored as part of the entity's data.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure Lesson entity
modelBuilder.Entity<Lesson>(builder =>
{
builder.HasKey(l => l.Id);
builder.Property(l => l.Title).IsRequired().HasMaxLength(200);
builder.Property(l => l.Description).IsRequired().HasMaxLength(2000);
builder.Property(l => l.Level).IsRequired();
builder.Property(l => l.CreatedAt).IsRequired();
builder.Property(l => l.UpdatedAt).IsRequired(false);
// Value object: GermanWord would be configured as an owned entity
// builder.OwnsMany(l => l.Words, wordBuilder =>
// {
// wordBuilder.Property(w => w.Value).HasColumnName("WordValue");
// wordBuilder.Property(w => w.Translation).HasColumnName("Translation");
// wordBuilder.Property(w => w.Article).HasColumnName("Article");
// wordBuilder.Property(w => w.PartOfSpeech).HasColumnName("PartOfSpeech");
// });
});
// Seed data (optional) - Note: For EF Core, we need to set properties directly
// In a real application, use migrations or a separate seeding mechanism
// modelBuilder.Entity<Lesson>().HasData(
// new { Id = 1, Title = "Greetings", Description = "Basic German greetings", Level = 1, CreatedAt = DateTime.UtcNow },
// new { Id = 2, Title = "Numbers", Description = "German numbers 1-100", Level = 1, CreatedAt = DateTime.UtcNow },
// new { Id = 3, Title = "Grammar Basics", Description = "Basic German grammar", Level = 2, CreatedAt = DateTime.UtcNow }
// );
}
}

View file

@ -0,0 +1,82 @@
using GermanApp.Domain.Entities;
using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Data.DbContext;
using Microsoft.EntityFrameworkCore;
namespace GermanApp.Infrastructure.Data.Repositories;
/// <summary>
/// Entity Framework Core implementation of ILessonRepository.
/// This is part of the Infrastructure layer.
/// </summary>
public class LessonRepository : ILessonRepository
{
private readonly AppDbContext _context;
public LessonRepository(AppDbContext context)
{
_context = context;
}
public async Task<Lesson?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Lessons
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Lesson>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.Lessons
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<Lesson> AddAsync(Lesson entity, CancellationToken cancellationToken = default)
{
await _context.Lessons.AddAsync(entity, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return entity;
}
public async Task UpdateAsync(Lesson entity, CancellationToken cancellationToken = default)
{
_context.Lessons.Update(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(Lesson entity, CancellationToken cancellationToken = default)
{
_context.Lessons.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> ExistsAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Lessons
.AnyAsync(l => l.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Lesson>> GetByLevelAsync(int level, CancellationToken cancellationToken = default)
{
return await _context.Lessons
.Where(l => l.Level == level)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Lesson>> GetBeginnerLessonsAsync(CancellationToken cancellationToken = default)
{
return await _context.Lessons
.Where(l => l.Level <= 2)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Lesson>> GetAdvancedLessonsAsync(CancellationToken cancellationToken = default)
{
return await _context.Lessons
.Where(l => l.Level >= 4)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
}

View file

@ -0,0 +1,140 @@
using GermanApp.Application.DTOs;
using GermanApp.Application.UseCases.Commands;
using GermanApp.Domain.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace GermanApp.Presentation.Endpoints;
/// <summary>
/// Minimal API endpoints for Lesson resources.
/// This is part of the Presentation layer.
/// </summary>
public static class LessonsEndpoints
{
public static void MapLessonsEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/lessons");
// GET /api/lessons
group.MapGet("/", async ([FromServices] ILessonRepository repository) =>
{
var lessons = await repository.GetAllAsync();
return Results.Ok(lessons.Select(l => l.ToDto()));
})
.WithName("GetAllLessons")
.WithOpenApi(operation => new(operation)
{
Summary = "Get all lessons",
Description = "Retrieves all available German lessons"
});
// GET /api/lessons/{id}
group.MapGet("/{id}", async ([FromRoute] int id, [FromServices] ILessonRepository repository) =>
{
var lesson = await repository.GetByIdAsync(id);
return lesson is null ? Results.NotFound() : Results.Ok(lesson.ToDto());
})
.WithName("GetLessonById")
.WithOpenApi(operation => new(operation)
{
Summary = "Get lesson by ID",
Description = "Retrieves a specific lesson by its identifier"
});
// GET /api/lessons/beginner
group.MapGet("/beginner", async ([FromServices] ILessonRepository repository) =>
{
var lessons = await repository.GetBeginnerLessonsAsync();
return Results.Ok(lessons.Select(l => l.ToDto()));
})
.WithName("GetBeginnerLessons")
.WithOpenApi(operation => new(operation)
{
Summary = "Get beginner lessons",
Description = "Retrieves all lessons at beginner level (A1-A2)"
});
// GET /api/lessons/advanced
group.MapGet("/advanced", async ([FromServices] ILessonRepository repository) =>
{
var lessons = await repository.GetAdvancedLessonsAsync();
return Results.Ok(lessons.Select(l => l.ToDto()));
})
.WithName("GetAdvancedLessons")
.WithOpenApi(operation => new(operation)
{
Summary = "Get advanced lessons",
Description = "Retrieves all lessons at advanced level (B2-C1)"
});
// GET /api/lessons/level/{level}
group.MapGet("/level/{level}", async ([FromRoute] int level, [FromServices] ILessonRepository repository) =>
{
var lessons = await repository.GetByLevelAsync(level);
return Results.Ok(lessons.Select(l => l.ToDto()));
})
.WithName("GetLessonsByLevel")
.WithOpenApi(operation => new(operation)
{
Summary = "Get lessons by level",
Description = "Retrieves all lessons at a specific proficiency level"
});
// POST /api/lessons
group.MapPost("/", async (
[FromBody] CreateLessonDto dto,
[FromServices] ICommandHandler<CreateLessonCommand, LessonDto> handler,
CancellationToken cancellationToken) =>
{
var command = new CreateLessonCommand(dto);
var result = await handler.Handle(command, cancellationToken);
return Results.Created($"/api/lessons/{result.Id}", result);
})
.WithName("CreateLesson")
.WithOpenApi(operation => new(operation)
{
Summary = "Create a new lesson",
Description = "Creates a new German lesson"
});
// PUT /api/lessons/{id}
group.MapPut("/{id}", async (
[FromRoute] int id,
[FromBody] UpdateLessonDto dto,
[FromServices] ILessonRepository repository) =>
{
var existingLesson = await repository.GetByIdAsync(id);
if (existingLesson is null)
return Results.NotFound();
// Map DTO to entity
existingLesson.Update(dto.Title, dto.Description, dto.Level);
await repository.UpdateAsync(existingLesson);
return Results.Ok(existingLesson.ToDto());
})
.WithName("UpdateLesson")
.WithOpenApi(operation => new(operation)
{
Summary = "Update a lesson",
Description = "Updates an existing lesson"
});
// DELETE /api/lessons/{id}
group.MapDelete("/{id}", async ([FromRoute] int id, [FromServices] ILessonRepository repository) =>
{
var lesson = await repository.GetByIdAsync(id);
if (lesson is null)
return Results.NotFound();
await repository.DeleteAsync(lesson);
return Results.NoContent();
})
.WithName("DeleteLesson")
.WithOpenApi(operation => new(operation)
{
Summary = "Delete a lesson",
Description = "Deletes a lesson by its identifier"
});
}
}

99
GermanApp/Program.cs Normal file
View file

@ -0,0 +1,99 @@
using GermanApp.Application.DTOs;
using GermanApp.Application.UseCases.Commands;
using GermanApp.Domain.Interfaces;
using GermanApp.Infrastructure.Data.DbContext;
using GermanApp.Infrastructure.Data.Repositories;
using GermanApp.Presentation.Endpoints;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// ============================================
// INFRASTRUCTURE LAYER - Database & External Services
// ============================================
// Add DbContext with SQLite (can be changed to SQL Server, PostgreSQL, etc.)
builder.Services.AddDbContext<AppDbContext>(options =>
{
// Using SQLite for development - configure in appsettings.json for production
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
?? "Data Source=germanapp.db");
// Enable sensitive data logging in development
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});
// Register repositories (Infrastructure implementations of Domain interfaces)
builder.Services.AddScoped<ILessonRepository, LessonRepository>();
// ============================================
// APPLICATION LAYER - Use Cases & Services
// ============================================
// Register command handlers
builder.Services.AddScoped<ICommandHandler<CreateLessonCommand, LessonDto>, CreateLessonCommandHandler>();
// ============================================
// PRESENTATION LAYER - API
// ============================================
// Add services for Minimal APIs
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
}
// Initialize database (in development)
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
dbContext.Database.EnsureCreated();
}
// Map Clean Architecture endpoints
app.MapLessonsEndpoints();
// Keep original WeatherForecast endpoint for reference
app.MapGet("/weatherforecast", () =>
{
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.Run();
// Existing record for reference
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5235",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}