Development Guide
Introduction
This guide covers the development principles, step-by-step workflows for adding new features, configuration management, naming conventions, and pull request standards for the Ravenxcope backend. Follow these patterns to maintain consistency across the codebase.
Development Principles
-
Keep
Program.csdeclarative — All startup logic should live in extension methods.Program.csshould read like a sequence of high-level steps, not contain implementation details. -
Register new dependencies through extension methods — Add new services, options, and health checks in the appropriate extension class (
ServiceCollectionExtensions, etc.). -
Use typed options for configuration — Never read
IConfiguration["key"]directly in service or controller code. Instead, define an options class inBackendConfiguration.csand injectIOptions<T>. -
Prefer services for business logic — Keep controllers thin with only transport concern (HTTP request → service call → HTTP response). Business logic, validation, and orchestration should live in service classes.
-
Organization-scoped data access — Always scope data queries to the user's organization (from JWT claims) unless the endpoint is explicitly cross-organization.
-
Fail-fast for required values — If a configuration key is required, add it to the startup validation. Never silently fall back to a default for production-critical values.
Step-by-Step: Adding a New CRUD Feature
This walkthrough demonstrates adding a complete new feature to the backend, using an Asset entity as the example.
Example Scenario: Adding an
Assetentity with full CRUD API endpoints.
Step 1: Define the Entity
Create the entity class in Domain/Entities/:
File: Domain/Entities/Asset.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Ravenxcope.Backend.Domain.Entities;
[Table("assets")]
public class Asset
{
[Key]
[Column("id")]
public Guid Id { get; set; } = Guid.CreateVersion7();
[Required]
[Column("name")]
[MaxLength(255)]
public string Name { get; set; } = string.Empty;
[Column("organization_id")]
public Guid OrganizationId { get; set; }
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Column("updated_at")]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
[ForeignKey("OrganizationId")]
public Organization Organization { get; set; } = null!;
}
Key conventions:
- Namespace:
Ravenxcope.Backend.Domain.Entities - Table name: lowercase plural (
assets) - Column names: lowercase with underscores (
organization_id) - Use
Guid.CreateVersion7()for time-ordered UUIDs - Include
created_atandupdated_attimestamps
Step 2: Register in DbContext
File: Infrastructure/Data/ApplicationDbContext.cs
// 1. Add DbSet property
public DbSet<Asset> Assets { get; set; }
// 2. Add configurations in OnModelCreating
modelBuilder.Entity<Asset>(entity =>
{
entity.HasOne(a => a.Organization)
.WithMany()
.HasForeignKey(a => a.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
});
// 3. Add to UpdateTimestamps method
var entries = context.ChangeTracker.Entries()
.Where(e => e.Entity is User || e.Entity is Organization || /* ... */
e.Entity is Asset); // Add new entity here
Step 3: Create DTOs
Create request/response DTOs in Application/DTOs/:
File: Application/DTOs/AssetDtos.cs
namespace Ravenxcope.Backend.DTOs;
public sealed record AssetDto(
Guid Id,
string Name,
Guid OrganizationId,
DateTime CreatedAt,
DateTime UpdatedAt);
public class CreateAssetRequest
{
public string Name { get; set; } = string.Empty;
}
public class UpdateAssetRequest
{
public string? Name { get; set; }
}
Step 4: Create Repository
Create a repository to handle data access.
File: Infrastructure/Repositories/AssetRepository.cs
using Ravenxcope.Backend.Data;
using Ravenxcope.Backend.Domain.Entities;
namespace Ravenxcope.Backend.Repositories;
public interface IAssetRepository : IRepository<Asset>
{
Task<IReadOnlyList<Asset>> GetByOrganizationIdAsync(Guid organizationId, CancellationToken cancellationToken = default);
}
public class AssetRepository : EfRepository<Asset>, IAssetRepository
{
public AssetRepository(ApplicationDbContext context) : base(context)
{
}
public async Task<IReadOnlyList<Asset>> GetByOrganizationIdAsync(Guid organizationId, CancellationToken cancellationToken = default)
{
return await _context.Assets
.Where(a => a.OrganizationId == organizationId)
.OrderBy(a => a.Name)
.ToListAsync(cancellationToken);
}
}
Step 5: Create Service
Create a service to orchestrate domain logic.
File: Infrastructure/Services/AssetService.cs
using Ravenxcope.Backend.DTOs;
using Ravenxcope.Backend.Repositories;
namespace Ravenxcope.Backend.Services;
public interface IAssetService
{
Task<IReadOnlyList<AssetDto>> GetAssetsAsync(Guid organizationId, CancellationToken cancellationToken = default);
}
public class AssetService : IAssetService
{
private readonly IAssetRepository _assetRepository;
public AssetService(IAssetRepository assetRepository)
{
_assetRepository = assetRepository;
}
public async Task<IReadOnlyList<AssetDto>> GetAssetsAsync(Guid organizationId, CancellationToken cancellationToken = default)
{
var assets = await _assetRepository.GetByOrganizationIdAsync(organizationId, cancellationToken);
return assets.Select(a => new AssetDto(a.Id, a.Name, a.OrganizationId, a.CreatedAt, a.UpdatedAt)).ToList();
}
}
Step 6: Create the Controller
File: API/Controllers/Assets/AssetsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Ravenxcope.Backend.DTOs;
using Ravenxcope.Backend.Services;
using Ravenxcope.Backend.Helpers;
namespace Ravenxcope.Backend.Controllers.Assets;
[ApiController]
[Route("api/assets")]
[Authorize]
public class AssetsController : ControllerBase
{
private readonly IAssetService _assetService;
public AssetsController(IAssetService assetService)
{
_assetService = assetService;
}
[HttpGet]
public async Task<IActionResult> GetAssets(CancellationToken cancellationToken)
{
var organizationId = UserHelper.GetOrganizationId(User);
if (!organizationId.HasValue)
{
return BadRequest(ApiEnvelope.Error("Organization ID not found in token", HttpContext.TraceIdentifier));
}
var assets = await _assetService.GetAssetsAsync(organizationId.Value, cancellationToken);
return Ok(ApiEnvelope.Success(new
{
items = assets,
count = assets.Count
}));
}
}
Controller conventions:
- Grouped in feature subdirectory (
Controllers/Assets/) - Route:
api/{resource}(lowercase plural, no version prefix) - Inherits
ControllerBasedirectly - Delegate all logic to scoped service (
IAssetService) - Return standardized
ApiEnvelope.Success/ApiEnvelope.Error - No manual try-catch: Exceptions are handled by global exception middleware.
Step 7: Register Services and Migrations
- Register in
ServiceCollectionExtensions.cs:services.AddScoped<IAssetRepository, AssetRepository>();services.AddScoped<IAssetService, AssetService>(); - Create and run migration:
dotnet ef migrations add AddAssetEntitydotnet ef database update
Summary — Files Created
| Layer | File |
|---|---|
| Domain | Domain/Entities/Asset.cs |
| Infrastructure | Infrastructure/Data/ApplicationDbContext.cs (modified) |
| Application | Application/DTOs/AssetDtos.cs |
| Infrastructure | Infrastructure/Repositories/AssetRepository.cs |
| Infrastructure | Infrastructure/Services/AssetService.cs |
| API | API/Controllers/Assets/AssetsController.cs |
| Extensions | Extensions/ServiceCollectionExtensions.cs (modified) |
Step-by-Step: Adding a New Configuration Section
When a new feature requires configuration:
Step 1: Define Typed Options Class
File: Extensions/BackendConfiguration.cs
// 1. Add section constant
public const string MyFeatureSection = "MyFeature";
public const string MyFeatureApiUrlKey = "MyFeature:ApiUrl";
// 2. Add options class
public sealed class MyFeatureOptions
{
public string ApiUrl { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 30;
}
Step 2: Add to appsettings.json
{
"MyFeature": {
"ApiUrl": "{{MyFeature__ApiUrl}}",
"TimeoutSeconds": 30
}
}
Step 3: Register Options Binding
File: Extensions/ServiceCollectionExtensions.cs
var myFeatureSection = configuration.GetSection(BackendConfigurationKeys.MyFeatureSection);
services.Configure<MyFeatureOptions>(myFeatureSection);
Step 4: Add Startup Validation (if required)
File: Extensions/ConfigurationValidationExtensions.cs
var requiredKeys = new[]
{
// ... existing keys ...
BackendConfigurationKeys.MyFeatureApiUrlKey, // Add here
};
Step 5: Inject in Service
public class MyFeatureService
{
private readonly MyFeatureOptions _options;
public MyFeatureService(IOptions<MyFeatureOptions> options)
{
_options = options.Value;
}
}
Step-by-Step: Adding a New Infrastructure Service
Step 1: Create Service Class
File: Infrastructure/Services/MyFeatureService.cs
using Microsoft.Extensions.Options;
using Ravenxcope.Backend.Extensions;
namespace Ravenxcope.Backend.Services;
public class MyFeatureService
{
private readonly MyFeatureOptions _options;
private readonly ILogger<MyFeatureService> _logger;
public MyFeatureService(
IOptions<MyFeatureOptions> options,
ILogger<MyFeatureService> logger)
{
_options = options.Value;
_logger = logger;
}
// Add service methods...
}
Step 2: Register Service
File: Extensions/ServiceCollectionExtensions.cs
// Choose lifetime based on usage:
services.AddSingleton<MyFeatureService>(); // Stateless, thread-safe
services.AddScoped<MyFeatureService>(); // Per-request state
Step 3: Add Health Check (if external dependency)
File: Extensions/StartupDependencyHealthChecksExtensions.cs
allPassed &= await HealthCheckHelper.RunHealthCheckWithRetry<IOptions<MyFeatureOptions>>(
services,
logger,
async (options, cancellationToken) =>
{
// Implement connectivity check
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(options.Value.ApiUrl, cancellationToken);
response.EnsureSuccessStatusCode();
},
"MyFeature");
Naming Conventions
Entity Naming
| Item | Convention | Example |
|---|---|---|
| Entity class | PascalCase singular | Sensor, VirtualSensor |
| Table name | lowercase plural/snake | sensors, virtual_sensors |
| Column name | lowercase snake_case | sensor_ip, created_at |
| Primary key | id | id |
| Foreign key | {referenced}_id | organization_id |
| Timestamp columns | created_at, updated_at | — |
| Namespace | Ravenxcope.Backend.Domain.Entities | Aligned with folder structure |
DTO Naming
| Item | Convention | Example |
|---|---|---|
| Create DTO | Create{Entity}Request | CreateSensorRequest |
| Update DTO | Update{Entity}Request | UpdateSensorRequest |
| Login DTO | Descriptive name | LoginRequest |
| Metrics DTO | {Feature}MetricsDto | SensorHeartbeatMetricsDto |
| Namespace | Ravenxcope.Backend.DTOs | — |
Controller Naming
| Item | Convention | Example |
|---|---|---|
| Controller class | {Feature}Controller (plural) | SensorsController, UsersController |
| Controller directory | Controllers/{Feature}/ | Controllers/Sensors/ |
| Route | api/{resource} (lowercase plural) | api/sensors, api/users |
| Namespace | Ravenxcope.Backend.Controllers.{Feature} | Ravenxcope.Backend.Controllers.Sensors |
Service Naming
| Item | Convention | Example |
|---|---|---|
| Service class | {Name}Service | JwtService, RedisService |
| Interface | I{Name}Service | IOpenSearchService |
| Namespace | Ravenxcope.Backend.Services | — |
API Route Conventions
| Pattern | Method | Example |
|---|---|---|
api/{resource} | GET | api/sensors (list all) |
api/{resource}/paginated | GET | api/users/paginated?page=1&pageSize=10 |
api/{resource}/{id} | GET | api/sensors/{id} (get by ID) |
api/{resource} | POST | api/sensors (create) |
api/{resource}/{id} | PUT | api/sensors/{id} (update) |
api/{resource}/{id} | DELETE | api/sensors/{id} (delete) |
Note: Unlike the Sindika boilerplate conventions, this project does not use a
v1version prefix in routes and uses GET (not POST) for pagination.
Configuration Workflow
Adding a Required Configuration Key
1. Define constant in BackendConfigurationKeys
→ Extensions/BackendConfiguration.cs
2. Define typed options class (if new section)
→ Extensions/BackendConfiguration.cs
3. Add to appsettings.json with placeholder value
→ appsettings.json
4. Add actual value to development config
→ appsettings.Development.json
5. Register options binding in service registration
→ Extensions/ServiceCollectionExtensions.cs
6. Add to required keys validation
→ Extensions/ConfigurationValidationExtensions.cs
7. Add positive integer validation if applicable
→ Extensions/ConfigurationValidationExtensions.cs
8. Add to preflight script if deployment-critical
→ defense_center/scripts/preflight-env.sh
Pull Request Checklist
| Category | Check |
|---|---|
| Build | dotnet build succeeds with no errors |
| Startup | Application starts successfully with development config |
| Config | New required keys added to validation |
| Config | New keys added to appsettings.json with placeholders |
| Migration | dotnet ef migrations add generates correct migration |
| Migration | dotnet ef database update applies without errors |
| Endpoints | New endpoints return correct HTTP status codes |
| Auth | [Authorize] applied to protected endpoints |
| Org Scoping | Data queries filter by organization from JWT claims |
| Logging | Structured logging with relevant context |
| Swagger | New endpoints appear correctly in Swagger UI |
| Documentation | Knowledge chapter updated if applicable |
Common Anti-Patterns to Avoid
Avoid direct configuration reads in controllers
// BAD — scattered IConfiguration reads
var url = _configuration["AnsibleSettings:ServiceUrl"];
// GOOD — typed options injection
public class MyService(IOptions<AnsibleSettingsOptions> options)
{
private readonly string _url = options.Value.ServiceUrl;
}
Avoid business logic in controllers
// BAD — orchestration logic in controller
[HttpPost("activate")]
public async Task<IActionResult> Activate(Guid id)
{
var sensor = await _context.Sensors.FindAsync(id);
// ... 50 lines of business logic ...
// ... multiple service calls ...
// ... error handling ...
}
// GOOD — delegate to service
[HttpPost("activate")]
public async Task<IActionResult> Activate(Guid id)
{
var result = await _sensorsService.ActivateSensorAsync(id, request);
return Ok(ApiEnvelope.Success(result));
}
Avoid silent fallback for required secrets
// BAD — silently using default
var secret = configuration["JwtSettings:Secret"] ?? "default-secret";
// GOOD — fail-fast
var secret = configuration["JwtSettings:Secret"]
?? throw new InvalidOperationException("JWT Secret is not configured");
Avoid inconsistent response shapes
// BAD — different shapes per endpoint
return Ok(users); // Endpoint A
return Ok(new { data = users }); // Endpoint B
return Ok(new { success = true, data = users }); // Endpoint C
// GOOD — consistent envelope
return Ok(ApiEnvelope.Success(new { items = users, count = users.Count }));
Avoid hard delete without consideration
// CAUTION — some entities use hard delete, verify intent
_context.Users.Remove(user); // Permanent, cascade deletes related records
EF Core Migration Guide
Creating a Migration
dotnet ef migrations add {DescriptiveName}
Good migration names:
AddAssetEntityAddIpAddressToSensorCreatePermissionRoleJunctionUpdateSensorStatusDefaults
Applying Migrations
# Apply all pending migrations
dotnet ef database update
# Apply to a specific migration
dotnet ef database update {MigrationName}
Removing the Last Migration
dotnet ef migrations remove
Auto-Migration at Startup
The application automatically applies pending migrations on startup when Database:AutoMigrate is true (default). Disable for production environments where migrations should be applied manually:
{
"Database": {
"AutoMigrate": false
}
}
Debugging Tips
Startup Failure Diagnosis
| Symptom | Likely Cause | Fix |
|---|---|---|
Missing required configuration | Placeholder or missing env var | Set environment variable or dev config |
Unable to connect to PostgreSQL | Database not running or wrong creds | Verify connection string in dev config |
Redis connection failed | Redis not running | Start Redis, verify host/port |
InfluxDB health check returned 4xx | Wrong URL, token, or service down | Verify InfluxDB config and service |
OpenSearch health check returned 4xx | Wrong credentials or SSL issues | Verify OpenSearch config |
JWT Secret must be at least 32 characters | Secret too short | Use a longer secret in dev config |
Useful Serilog Overrides for Development
{
"Serilog": {
"MinimumLevel": {
"Override": {
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}
}
This enables EF Core SQL query logging for debugging database issues.