Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ See [docs/CONSTITUTION.md](docs/CONSTITUTION.md) for the authoritative reference
- Module boundaries, dependencies, and data ownership
- Communication patterns (contracts and events)
- Endpoint, frontend, and authorization rules
- Compiler-enforced diagnostics (SM0001-SM0044)
- Compiler-enforced diagnostics (SM0001-SM0054)
- Framework contributor guidelines

## Key Constraints
Expand Down
33 changes: 29 additions & 4 deletions docs/CONSTITUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,14 +462,23 @@ All SM diagnostics are emitted by the Roslyn source generator at compile time. `
|------------|----------|------|
| SM0039 | Warning | Interceptor has transitive DbContext dependency (resolve at interception time) |

### Localization
### Feature Flags

| Diagnostic | Severity | Rule |
|------------|----------|------|
| SM0049 | Warning | Module has `IStringLocalizer` injection but no `Locales/en.json` embedded resource |
| SM0050 | Warning | `Locales/en.json` exists but is not marked as `EmbeddedResource` in `.csproj` |
| SM0045 | Error | Feature class must be sealed |
| SM0046 | Warning | Feature field must follow `ModuleName.FeatureName` pattern |
| SM0047 | Error | No duplicate feature names across modules |
| SM0048 | Error | Feature field must be a public const string |

### Module Structure
### Endpoints

| Diagnostic | Severity | Rule |
|------------|----------|------|
| SM0049 | Error | Each endpoint must be in its own file |
| SM0054 | Info | Endpoint should declare a `public const string Route` field |

### Module Metadata

| Diagnostic | Severity | Rule |
|------------|----------|------|
Expand Down Expand Up @@ -507,6 +516,22 @@ All SM diagnostics are emitted by the Roslyn source generator at compile time. `
- Interceptors are resolved lazily to avoid circular DI.
- `ApplyModuleSchema` must handle PostgreSQL, SQL Server, and SQLite.

### Database Migrations

- One migration history shared by all modules. Run `dotnet ef migrations add <Name> --project template/SimpleModule.Host` to create a migration.
- The unified `HostDbContext` (source-generated) owns all DbSets across modules. Migrations target this context.
- When two modules add migrations concurrently, resolve conflicts by regenerating the later migration against the merged model snapshot.
- SQLite uses table prefixes (`{ModuleName}_`) for logical isolation. PostgreSQL and SQL Server use schema isolation (`{ModuleName}.`).
- Never modify or delete existing migrations that have been applied in production. Add corrective migrations instead.

### Logging Conventions

- Inject `ILogger<T>` via primary constructor. Use the module's service class as the type parameter.
- Use source-generated logging via `[LoggerMessage]` attribute for all log messages. This is required by the `partial class` pattern and produces high-performance, zero-allocation log calls.
- **Log levels**: `Debug` for lifecycle events (module started/stopped). `Information` for successful operations (entity created/updated/deleted). `Warning` for expected failures (not found, validation). `Error` for unexpected failures (exceptions, infrastructure).
- **Structured fields**: Always include entity IDs and names as named parameters (e.g., `{ProductId}`, `{ProductName}`). The runtime logging infrastructure adds correlation IDs via `System.Diagnostics.Activity.Current.TraceId`.
- Do not log sensitive data (passwords, tokens, PII). The AuditLogs module handles redaction for audit trails separately.

### Core Framework

- All `IModule` methods must have default implementations.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
public static class HealthCheckConstants
{
public const string DatabaseCheckName = "database";
public const string ModulesCheckName = "modules";
public const string ReadyTag = "ready";
public const string AllDatabasesReachable = "All module databases are reachable.";
public const string DatabaseHealthCheckFailed = "Database health check failed.";
Expand Down
83 changes: 83 additions & 0 deletions framework/SimpleModule.Core/Health/ModuleHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace SimpleModule.Core.Health;

/// <summary>
/// Aggregates health status from all discovered modules by calling
/// <see cref="IModule.CheckHealthAsync"/> on each one in parallel.
/// </summary>
public sealed class ModuleHealthCheck : IHealthCheck
{
private readonly (IModule Module, string Name)[] _modules;

public ModuleHealthCheck(IEnumerable<IModule> modules)
{
_modules = modules.Select(m => (m, GetModuleName(m))).ToArray();
}

public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default
)
{
var tasks = _modules.Select(entry =>
CheckOneAsync(entry.Module, entry.Name, cancellationToken)
);
var results = await Task.WhenAll(tasks);

var data = new Dictionary<string, object>(results.Length);
var worstStatus = ModuleHealthStatus.Healthy;
foreach (var (name, status, detail) in results)
{
data[name] = detail;
if (status > worstStatus)
{
worstStatus = status;
}
}

return worstStatus switch
{
ModuleHealthStatus.Healthy => HealthCheckResult.Healthy(
"All modules are healthy.",
data
),
ModuleHealthStatus.Degraded => HealthCheckResult.Degraded(
"One or more modules are degraded.",
data: data
),
_ => HealthCheckResult.Unhealthy("One or more modules are unhealthy.", data: data),
};
}

[SuppressMessage(
"Design",
"CA1031:Do not catch general exception types",
Justification = "Health check must report failures, not throw"
)]
private static async Task<(
string Name,
ModuleHealthStatus Status,
string Detail
)> CheckOneAsync(IModule module, string name, CancellationToken cancellationToken)
{
try
{
var status = await module.CheckHealthAsync(cancellationToken);
return (name, status, status.ToString());
}
catch (Exception ex)
{
return (name, ModuleHealthStatus.Unhealthy, $"Error: {ex.Message}");
}
}

private static string GetModuleName(IModule module)
{
var type = module.GetType();
var attribute = type.GetCustomAttribute<ModuleAttribute>();
return attribute?.Name ?? type.Name;
}
}
109 changes: 108 additions & 1 deletion framework/SimpleModule.Core/Validation/ValidationBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Text.RegularExpressions;

namespace SimpleModule.Core.Validation;

public sealed class ValidationBuilder
public sealed partial class ValidationBuilder
{
private readonly Dictionary<string, List<string>> _errors = [];

Expand All @@ -20,6 +22,108 @@ public ValidationBuilder AddErrorIf(bool condition, string field, string message
return this;
}

public ValidationBuilder Required(string? value, string field, string? message = null)
{
return AddErrorIf(
string.IsNullOrWhiteSpace(value),
field,
message ?? $"{field} is required."
);
}

public ValidationBuilder MaxLength(
string? value,
string field,
int maxLength,
string? message = null
)
{
return AddErrorIf(
value is not null && value.Length > maxLength,
field,
message ?? $"{field} must be at most {maxLength} characters."
);
}

public ValidationBuilder MinLength(
string? value,
string field,
int minLength,
string? message = null
)
{
return AddErrorIf(
value is not null && value.Length < minLength,
field,
message ?? $"{field} must be at least {minLength} characters."
);
}

public ValidationBuilder LengthBetween(
string? value,
string field,
int minLength,
int maxLength,
string? message = null
)
{
return AddErrorIf(
value is not null && (value.Length < minLength || value.Length > maxLength),
field,
message ?? $"{field} must be between {minLength} and {maxLength} characters."
);
}

public ValidationBuilder MatchesPattern(
string? value,
string field,
string pattern,
string? message = null
)
{
// Static Regex.IsMatch caches a small set of compiled regexes internally,
// which is fine for the handful of patterns typical callers use.
return AddErrorIf(
value is not null && !Regex.IsMatch(value, pattern, RegexOptions.Compiled),
field,
message ?? $"{field} has an invalid format."
);
}

public ValidationBuilder Email(string? value, string field, string? message = null)
{
return AddErrorIf(
!string.IsNullOrWhiteSpace(value) && !EmailRegex().IsMatch(value),
field,
message ?? $"{field} must be a valid email address."
);
}

public ValidationBuilder GreaterThan(
decimal value,
string field,
decimal min,
string? message = null
)
{
return AddErrorIf(value <= min, field, message ?? $"{field} must be greater than {min}.");
}

public ValidationBuilder Between(
decimal value,
string field,
decimal min,
decimal max,
string? message = null
)
{
return AddErrorIf(
value < min || value > max,
field,
message ?? $"{field} must be between {min} and {max}."
);
}

public ValidationResult Build()
{
if (_errors.Count == 0)
Expand All @@ -30,4 +134,7 @@ public ValidationResult Build()
var errors = _errors.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray());
return ValidationResult.WithErrors(errors);
}

[GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled)]
private static partial Regex EmailRegex();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace SimpleModule.Generator;

/// <summary>
/// Emits a registry of all discovered contract interfaces and their implementations.
/// This allows test infrastructure to enumerate and resolve every contract at runtime,
/// catching circular dependencies that only manifest during DI resolution.
/// </summary>
internal sealed class ContractRegistryEmitter : IEmitter
{
public void Emit(SourceProductionContext context, DiscoveryData data)
{
if (data.ContractImplementations.Length == 0)
return;

// Group by interface and only include interfaces with exactly one valid implementation
// (matching what ModuleExtensionsEmitter actually registers with the DI container).
var implsByInterface = new Dictionary<string, List<ContractImplementationRecord>>();
foreach (var impl in data.ContractImplementations)
{
if (!impl.IsPublic || impl.IsAbstract)
continue;

if (!implsByInterface.TryGetValue(impl.InterfaceFqn, out var list))
{
list = new List<ContractImplementationRecord>();
implsByInterface[impl.InterfaceFqn] = list;
}
list.Add(impl);
}

var singleImpls = implsByInterface
.Where(kvp => kvp.Value.Count == 1)
.OrderBy(kvp => kvp.Key)
.ToArray();

if (singleImpls.Length == 0)
return;

var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#pragma warning disable");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine("namespace SimpleModule.Core;");
sb.AppendLine();
sb.AppendLine("/// <summary>");
sb.AppendLine(
"/// Registry of all discovered module contract interfaces that have exactly one"
);
sb.AppendLine(
"/// registered implementation. Enables tests to enumerate and resolve every contract"
);
sb.AppendLine(
"/// without having to hardcode the list, catching new circular dependencies as modules"
);
sb.AppendLine("/// are added.");
sb.AppendLine("/// </summary>");
sb.AppendLine("public static class ModuleContractRegistry");
sb.AppendLine("{");
sb.AppendLine(" public static System.Type[] All { get; } = new System.Type[]");
sb.AppendLine(" {");

foreach (var kvp in singleImpls)
{
sb.AppendLine($" typeof({kvp.Key}),");
}

sb.AppendLine(" };");
sb.AppendLine("}");

context.AddSource("ContractRegistry.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class ModuleDiscovererGenerator : IIncrementalGenerator
new HostDbContextEmitter(),
new ValueConverterConventionsEmitter(),
new DbContextRegistryEmitter(),
new ContractRegistryEmitter(),
new AgentExtensionsEmitter(),
new LocalizationExtensionsEmitter(),
new RoutesEmitter(),
Expand Down
Loading
Loading