Why No Direct Database Access
App Studio was built with a deliberate constraint: zero direct database access in the App Studio project itself. All reads and writes flow through IAppStudioService. This section explains the reasoning and what the service layer enforces that raw DB access would not.
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.
IAppStudioService and implement it in AIExtension.Service. This is the only approved pattern.