Skip to main content

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

  1. Keep Program.cs declarative — All startup logic should live in extension methods. Program.cs should read like a sequence of high-level steps, not contain implementation details.

  2. Register new dependencies through extension methods — Add new services, options, and health checks in the appropriate extension class (ServiceCollectionExtensions, etc.).

  3. Use typed options for configuration — Never read IConfiguration["key"] directly in service or controller code. Instead, define an options class in BackendConfiguration.cs and inject IOptions<T>.

  4. 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.

  5. Organization-scoped data access — Always scope data queries to the user's organization (from JWT claims) unless the endpoint is explicitly cross-organization.

  6. 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 Asset entity 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_at and updated_at timestamps

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 ControllerBase directly
  • 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

  1. Register in ServiceCollectionExtensions.cs:
    services.AddScoped<IAssetRepository, AssetRepository>();
    services.AddScoped<IAssetService, AssetService>();
  2. Create and run migration:
    dotnet ef migrations add AddAssetEntity
    dotnet ef database update

Summary — Files Created

LayerFile
DomainDomain/Entities/Asset.cs
InfrastructureInfrastructure/Data/ApplicationDbContext.cs (modified)
ApplicationApplication/DTOs/AssetDtos.cs
InfrastructureInfrastructure/Repositories/AssetRepository.cs
InfrastructureInfrastructure/Services/AssetService.cs
APIAPI/Controllers/Assets/AssetsController.cs
ExtensionsExtensions/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

ItemConventionExample
Entity classPascalCase singularSensor, VirtualSensor
Table namelowercase plural/snakesensors, virtual_sensors
Column namelowercase snake_casesensor_ip, created_at
Primary keyidid
Foreign key{referenced}_idorganization_id
Timestamp columnscreated_at, updated_at
NamespaceRavenxcope.Backend.Domain.EntitiesAligned with folder structure

DTO Naming

ItemConventionExample
Create DTOCreate{Entity}RequestCreateSensorRequest
Update DTOUpdate{Entity}RequestUpdateSensorRequest
Login DTODescriptive nameLoginRequest
Metrics DTO{Feature}MetricsDtoSensorHeartbeatMetricsDto
NamespaceRavenxcope.Backend.DTOs

Controller Naming

ItemConventionExample
Controller class{Feature}Controller (plural)SensorsController, UsersController
Controller directoryControllers/{Feature}/Controllers/Sensors/
Routeapi/{resource} (lowercase plural)api/sensors, api/users
NamespaceRavenxcope.Backend.Controllers.{Feature}Ravenxcope.Backend.Controllers.Sensors

Service Naming

ItemConventionExample
Service class{Name}ServiceJwtService, RedisService
InterfaceI{Name}ServiceIOpenSearchService
NamespaceRavenxcope.Backend.Services

API Route Conventions

PatternMethodExample
api/{resource}GETapi/sensors (list all)
api/{resource}/paginatedGETapi/users/paginated?page=1&pageSize=10
api/{resource}/{id}GETapi/sensors/{id} (get by ID)
api/{resource}POSTapi/sensors (create)
api/{resource}/{id}PUTapi/sensors/{id} (update)
api/{resource}/{id}DELETEapi/sensors/{id} (delete)

Note: Unlike the Sindika boilerplate conventions, this project does not use a v1 version 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

CategoryCheck
Builddotnet build succeeds with no errors
StartupApplication starts successfully with development config
ConfigNew required keys added to validation
ConfigNew keys added to appsettings.json with placeholders
Migrationdotnet ef migrations add generates correct migration
Migrationdotnet ef database update applies without errors
EndpointsNew endpoints return correct HTTP status codes
Auth[Authorize] applied to protected endpoints
Org ScopingData queries filter by organization from JWT claims
LoggingStructured logging with relevant context
SwaggerNew endpoints appear correctly in Swagger UI
DocumentationKnowledge 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:

  • AddAssetEntity
  • AddIpAddressToSensor
  • CreatePermissionRoleJunction
  • UpdateSensorStatusDefaults

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

SymptomLikely CauseFix
Missing required configurationPlaceholder or missing env varSet environment variable or dev config
Unable to connect to PostgreSQLDatabase not running or wrong credsVerify connection string in dev config
Redis connection failedRedis not runningStart Redis, verify host/port
InfluxDB health check returned 4xxWrong URL, token, or service downVerify InfluxDB config and service
OpenSearch health check returned 4xxWrong credentials or SSL issuesVerify OpenSearch config
JWT Secret must be at least 32 charactersSecret too shortUse 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.