Add a ServiceStack API
Define contracts in C#, implement policy in the service layer, then regenerate the TypeScript client.
Development recipes · Architecture
1. Define the contract
Add request, response, and public data types to MyApp.ServiceModel. Use an explicit route, HTTP marker interface, typed return, declarative validation, and authentication requirement.
[ValidateIsAuthenticated]
[RequiresFeature("widgets.basic")]
[Route("/saas/widgets", "POST")]
public class CreateWidget : IPost, IReturn<CreateWidgetResponse>
{
[ValidateNotEmpty]
public string Name { get; set; } = "";
[ValidateNotEmpty]
public string IdempotencyKey { get; set; } = "";
}
public class CreateWidgetResponse
{
public string Id { get; set; } = "";
public ResponseStatus? ResponseStatus { get; set; }
}Use opaque public IDs and stable error codes. Do not expose OrmLite rows when a smaller response projection is sufficient.
2. Implement organization policy
Implement the operation in MyApp.ServiceInterface. An API for an organization says which one in its Request DTO by implementing IRequireWorkspace. Before the API runs, the caller is checked to be a member of that organization and the request's database connections are confined to it. A table that implements IHasWorkspaceId can then only be read and written for that organization, and an API that uses one without implementing IRequireWorkspace throws.
public class Widget : SaasAuditBase, IHasWorkspaceId
{
[PrimaryKey] public string Id { get; set; } = Guid.NewGuid().ToString("N");
[References(typeof(Workspace)), Index] public string WorkspaceId { get; set; } = "";
public string Name { get; set; } = "";
public string IdempotencyKey { get; set; } = "";
}[ValidateIsAuthenticated]
[Route("/saas/widgets", "POST")]
public class CreateWidget : IPost, IReturn<CreateWidgetResponse>, IRequireWorkspace
{
public string WorkspaceId { get; set; } = default!;
[ValidateNotEmpty] public string Name { get; set; } = "";
[ValidateNotEmpty] public string IdempotencyKey { get; set; } = "";
}public class WidgetServices : Service
{
public object Any(CreateWidget request)
{
// The organization the connection is confined to, and the caller's membership of it
var scope = Db.GetWorkspaceScope();
WorkspaceAuthorization.RequireAdmin(scope); // only when needed
// Only this organization's widgets are searched
var existing = Db.Single<Widget>(x => x.IdempotencyKey == request.IdempotencyKey);
if (existing != null)
return new CreateWidgetResponse { Id = existing.Id };
// The connection sets WorkspaceId and the audit columns
var widget = new Widget {
Name = request.Name.Trim(),
IdempotencyKey = request.IdempotencyKey,
};
Db.Insert(widget);
return new CreateWidgetResponse { Id = widget.Id };
}
}
The WorkspaceId only says which organization a request is for. Whether the caller can use it comes from their membership, which is checked on every request. API-key calls work in the organization their key was created for and do not need to send one.
APIs are available to a suspended organization only when they say so. GET APIs need Read access and others need Write; add [WorkspaceAccess(WorkspaceAccess.Account)] to APIs that administer the organization itself.
In the client, pass the tab's organization with each call:
const api = await client.api(new CreateWidget({ workspaceId: tabWorkspaceId(), name, idempotencyKey }))Classify the new table as tenantOwned in features.json and add it to the migration. See Tenant isolation for what the connection enforces.
AutoQuery APIs
AutoQuery uses a request connection, so an AutoQuery API over a tenant-owned table fails unless it implements IRequireWorkspace too:
[ValidateIsAuthenticated]
[Route("/saas/widgets", "GET")]
public class QueryWidgets : QueryDb<Widget>, IRequireWorkspace
{
public string WorkspaceId { get; set; } = default!;
}The query then only returns that organization's widgets, and is rejected when the caller is not a member of it.
Let the database filter and page
In a shared database one large organization slows everyone if a query's cost grows with its data. Avoid selecting a table and then filtering, sorting, or paging the list in C#:
// Loads every widget of the organization to return 50
var rows = Db.Select<Widget>().Where(...).OrderByDescending(x => x.CreatedDate).ToList();
return new QueryWidgetsResponse { Total = rows.Count, Results = rows.Skip(skip).Take(50).ToList() };var q = Db.From<Widget>().Where(x => x.Status != WidgetStatus.Deleted);
if (!request.Search.IsNullOrEmpty())
{
var search = request.Search!.Trim().ToLowerInvariant();
q.And(x => x.Name.ToLower().Contains(search));
}
return new QueryWidgetsResponse {
Total = Db.Count(q),
Results = Db.Select(q.OrderByDescending(x => x.CreatedDate).ThenBy(x => x.Id).Limit(skip, take)),
};Db.From<T>()on a confined connection is confined, like every typed query.- Add a tie-breaker to the order (
ThenBy(x => x.Id)), or pages overlap when rows share a timestamp. - Compare text with
ToLower()on both sides.LIKEis case-insensitive on SQLite and case-sensitive on PostgreSQL, so a search that works in development can miss rows in production. - Count and group in the database:
Db.Count<T>(...), andGroupBywithSql.Count("*")for a breakdown. - For a search over several tables, find the matching IDs with one query per table, then page the result. See
QuerySaasCustomers.
Offset paging is used because the UI shows page numbers. For an endless list, SeekAfter() pages by the last row seen and stays fast at any depth. The audit log does this, taking the Id of the last event the page has shown:
q.OrderByDescending(x => x.CreatedDate).ThenBy(x => x.Id).Take(Math.Clamp(request.Take, 1, 200));
if (!request.AfterId.IsNullOrEmpty())
{
var last = Db.SingleById<SaasAuditEvent>(request.AfterId) // null for another organization's event
?? throw new HttpError(404, "AuditEventNotFound", "The audit event to continue after was not found.");
q.SeekAfter(last);
}Index by organization
Every query on a tenant-owned table is filtered by WorkspaceId, so an index that does not start with it is rarely the one that is used. Give each tenant-owned table an index or unique constraint that starts with WorkspaceId, followed by the columns it is queried by:
[UniqueConstraint(nameof(WorkspaceId), nameof(IdempotencyKey))]
[CompositeIndex(nameof(WorkspaceId), nameof(Status), nameof(CreatedDate))]
public class Widget : SaasAuditBase, IHasWorkspaceId- Business keys are unique within an organization, not globally:
(WorkspaceId, IdempotencyKey). - A unique constraint that starts with
WorkspaceIdalready serves as its index. A separate one is redundant. - Sweeps across organizations need their own index on what they sweep by, such as
(Status, ExpiresAt)for expiring reservations.
A guard test fails when a tenant-owned table has no index that starts with WorkspaceId.
3. Cover product invariants
- Apply
RequiresFeaturefor commercially controlled capability. - Use
WorkspaceAuthorizationfor organization roles andPlatformAuthorizationfor staff roles. A confined connection limits which rows are reachable; it does not replace role checks. - A row has its
WorkspaceIdand audit columns afterDb.Insert(row), so it can be returned without reading it back. - Add a unique database constraint for idempotency or business uniqueness, starting with
WorkspaceId. - Add
[ValidateHasScope("scope")]only when API keys should be able to call the API, with a scope fromSaasApiKeys.Scopes. Signed-in users have every scope. APIs without it need a signed-in user. - Meter accepted work through
ISaasManagerwhen it consumes quota. - Record a registered audit event for privileged or security-relevant changes.
- Enqueue slow or retryable external work only after durable state exists.
4. Generate and consume the client
Run the application, regenerate MyApp.Client/lib/dtos.ts, and call the request class through the shared JsonServiceClient.
const api = await client.api(new CreateWidget({
workspaceId: tabWorkspaceId(),
name,
idempotencyKey: crypto.randomUUID(),
}))
if (!api.succeeded)
setError(api.error?.message ?? 'Unable to create widget.')Never hand-edit generated DTOs or reproduce secret-dependent decisions in React.
5. Verify
Test unauthenticated access, missing entitlement, every allowed/denied role, validation, idempotent replay, cross-organization identifiers, a missing and a non-member WorkspaceId, read-only subscription access, and the successful typed client call. Finish with ./scripts/verify.sh.
Related documentation
Development recipes
These recipes are the shortest safe paths for extending Next SaaS while preserving typed contracts, organization isolation, entitlements, quota correctness, and static-export compatibility.
Add a feature gate
A feature key represents a stable product capability. Plans and customer overrides decide whether an organization receives it.