.NET React Templates
Development

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 };
    }
}

![Service Implementation and Context Resolution](../assets/reference-module-service.png)

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. LIKE is 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>(...), and GroupBy with Sql.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 WorkspaceId already 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 RequiresFeature for commercially controlled capability.
  • Use WorkspaceAuthorization for organization roles and PlatformAuthorization for staff roles. A confined connection limits which rows are reachable; it does not replace role checks.
  • A row has its WorkspaceId and audit columns after Db.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 from SaasApiKeys.Scopes. Signed-in users have every scope. APIs without it need a signed-in user.
  • Meter accepted work through ISaasManager when 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.

On this page