Portal Community

What "No Direct DB Access" Means

// ❌ This pattern does NOT exist in App Studio:
public class AppController : ControllerBase
{
    private readonly AppDbContext _db;

    public async Task GetApp(string appId)
    {
        var app = await _db.Apps
            .Where(a => a.AppId == appId && a.TenantId == TenantId)
            .FirstOrDefaultAsync();
        return Ok(app);
    }
}

// ✅ This is the actual pattern:
public class AppController : ControllerBase
{
    private readonly IAppStudioService _appService;

    public async Task GetApp(string appId)
    {
        var app = await _appService.GetAppAsync(TenantId, appId);
        return Ok(app);
    }
}

Reason 1: Multi-Tenancy Is Not Optional

In the direct DB approach, every query must include a .Where(a => a.TenantId == tenantId) filter. This is a human error risk — one missed filter means cross-tenant data leakage. The service layer makes this impossible:

// Service layer — TenantId is always a required parameter
// It is structurally impossible to call GetAppAsync without a tenantId
Task GetAppAsync(string tenantId, string appId);

// The implementation enforces it:
public async Task GetAppAsync(string tenantId, string appId)
{
    var cacheKey = $"app:{tenantId}:{appId}";
    // ... always scoped to tenantId, always
}

Reason 2: Caching Is a Cross-Cutting Concern

App definitions are read far more often than they are written — every app page load triggers a read. Without caching, this would hit the database on every request. With the service layer, caching is applied once in the implementation — every caller benefits automatically:

// Service layer implementation (AIExtension.Service):
// Caller (App Studio controller) sees none of this complexity
public async Task GetAppAsync(string tenantId, string appId)
{
    var cacheKey = $"appdef:{tenantId}:{appId}";

    // 1. Check Redis cache
    var cached = await _redis.GetAsync(cacheKey);
    if (cached != null) return cached;

    // 2. Load from database
    var entity = await _db.AppDefinitions
        .Where(a => a.TenantId == tenantId && a.AppId == appId)
        .FirstOrDefaultAsync();

    if (entity == null) return null;

    // 3. Cache and return
    await _redis.SetAsync(cacheKey, entity, TimeSpan.FromMinutes(15));
    return entity;
}

Reason 3: Audit Is Mandatory and Centralized

Every change to an app definition must be audit-logged. If controllers directly accessed the DB, each controller method would need to remember to write an audit entry. The service layer does it automatically on every write:

public async Task SaveAppAsync(string tenantId, AppDefinition app, string changedByUserId)
{
    // 1. Persist to DB
    await PersistAppAsync(tenantId, app);

    // 2. Invalidate cache
    await _redis.DeleteAsync($"appdef:{tenantId}:{app.AppId}");

    // 3. Write audit entry — always, no exceptions
    await _auditLogger.LogAsync(new AuditEntry
    {
        TenantId = tenantId,
        EntityType = "AppDefinition",
        EntityId = app.AppId,
        Action = "Save",
        ChangedBy = changedByUserId,
        ChangedAt = DateTime.UtcNow
    });
}

Reason 4: Clean Dependency Direction

App Studio is a consumer product built on top of the AIExtension platform. The dependency direction should be: App Studio depends on AIExtension, not the other way around. If App Studio had its own DbContext, the boundary would blur — AIExtension changes could break App Studio without a clear interface contract.

The interface IAppStudioService is the explicit, versioned contract between App Studio and the AIExtension platform. Breaking changes to the interface require updating both sides — the interface enforces this explicitly.

The rule is absolute No new App Studio feature should add a DbContext or repository to the App Studio project. If a new data operation is needed, add a method to IAppStudioService and implement it in AIExtension.Service. This is the only approved pattern.