ADR-007: Auto-Generated Multi-Language SDK for LBS Foundry API¶
Status¶
Accepted & Implemented - January 2025
- Phase 1-3: TypeScript SDK generator and client (Completed)
- Phase 4: Python SDK support (Completed)
- Phase 5: Import API support (Completed)
Context¶
Current State¶
LBS Foundry exposes a CQRS-based API with strongly-typed queries and commands in C#:
- Query Endpoint:
POST /api/readmodel- Accepts dynamic query objects with type discrimination viaqueryTypeproperty - Command Endpoint:
POST /api/command- Executes domain commands with strong typing - Type System: C# classes decorated with
[DataContract(Name = "...")]attributes for serialization - Authentication: Dual system (Clerk JWT + Basic Auth) with role-based authorization
Example C# Query:
[DataContract(Name = "PlayerSearch")]
public sealed class PlayerSearchQuery : IPublicQuery, IRequireUserContext
{
public string QueryType => this.GetType().GetQueryTypeName();
public List<string>? Teams { get; set; }
public string? SearchTerm { get; set; }
public int? MinPrice { get; set; }
public int? MaxPrice { get; set; }
public List<string>? Positions { get; set; }
public ClaimsPrincipal? User => UserContext.Current;
}
Current Frontend Integration:
// Manual type definitions, prone to drift
interface PlayerSearchQuery {
queryType: string;
teams?: string[];
searchTerm?: string;
minPrice?: number;
maxPrice?: number;
positions?: string[];
}
// Manual API calls with no type safety
const response = await axios.post('/api/readmodel', {
query: {
queryType: 'PlayerSearch',
teams: ['Panthers', 'Eels'],
minPrice: 200000
}
});
Problems with Current Approach¶
- Type Drift: Frontend types manually maintained, easily become outdated
- No Compile-Time Safety: Typos in
queryTypeor property names only caught at runtime - Discoverability: Developers must reference C# code to find available queries/commands
- Documentation Burden: Query structure changes require manual docs updates
- Error-Prone: Easy to send malformed requests that pass TypeScript but fail server validation
- No IntelliSense: IDE cannot provide autocomplete for query properties
- Testing Complexity: No shared type definitions between frontend tests and API contracts
Requirements¶
- High-Fidelity Types: TypeScript types must exactly match C# contracts (properties, nullability, types)
- Automated Generation: Types generated from C# source code during build process
- Lightweight Client: Minimal runtime overhead, simple API surface
- Build Integration: Works in both GitHub Actions (CI/CD) and local development
- npm Distribution: Published as versioned npm package for easy consumption
- Developer Experience: IntelliSense support, compile-time safety, clear error messages
- Extensibility: Support for future features (WebSocket updates, caching strategies, etc.)
Decision¶
We will implement an auto-generated TypeScript SDK using a custom Roslyn-based source generator that:
- Uses
[DataContract(Name, Namespace)]attributes as the source of truth for client-side API structure - Aligns with ModuleDefinition for simple, intuitive organization (Core, Sport, SuperCoach, Ballr, Discord)
- Generates TypeScript types with exact property mappings including nullability
- Uses modern object literals (not TypeScript
namespacekeyword) for better tree-shaking and JavaScript compatibility - Creates type-safe client methods for executing queries and commands
- Publishes to npm as
@lbs-foundry/typescript-sdkwith semantic versioning - Integrates with GitHub Actions for automated generation and publishing
Key Design Decisions¶
Namespace Source: [DataContract(Namespace = LbsNamespace.SuperCoachQueries)] - Explicit backend control
Module Alignment: Namespaces map to ModuleDefinition (Core, Sport, SuperCoach, Ballr, Discord) - Keep it simple
Modern TypeScript: Object literals (export const queries = { core: {...}, sport: {...} }) - Not namespace keyword
Start Simple: 5-7 top-level modules, can add sub-grouping later if needed
Architecture Overview¶
flowchart TB
subgraph "Build Pipeline"
A[C# Codebase] --> B[Source Scanner]
B --> C[Type Analyzer]
C --> D[TypeScript Generator]
D --> E[npm Package Builder]
E --> F[Package Publisher]
end
subgraph "Generated Package"
G[TypeScript Types]
H[Query Builder]
I[Command Executor]
J[Client Configuration]
end
subgraph "Consumer Applications"
K[Svelte Frontend]
L[Node.js Services]
M[Testing Tools]
end
F --> G
F --> H
F --> I
F --> J
G --> K
H --> K
I --> K
G --> L
H --> L
I --> L
G --> M
H --> M
I --> M
Implementation Plan¶
Phase 1: Type Scanner & Generator (Week 1)¶
Goal: Create tool to extract C# types and generate TypeScript
Code Generation Pipeline¶
The SDK generation follows this pipeline:
┌─────────────────────────────────────────────────────────────────────┐
│ 1. BUILD C# ASSEMBLIES │
│ dotnet build LBS.slnx │
│ → Produces: LBS.Domain.Core.dll, LBS.Ballr.Core.dll, etc. │
└─────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────┐
│ 2. RUN TYPE SCANNER (LBS.Tools.TypeScriptGenerator) │
│ dotnet run --project src/Tools/LBS.Tools.TypeScriptGenerator │
│ --assemblies "src/Domain/*/bin/Debug/net10.0/*.dll" │
│ --output "./sdk/typescript/src/generated" │
└─────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────┐
│ 3. SCAN ASSEMBLIES WITH ROSLYN │
│ • Load each DLL with MetadataLoadContext │
│ • Find all types with [DataContract] attribute │
│ • Filter for IQuery, ICommand, response contracts │
│ • Extract properties, XML docs, RequiresRoles, etc. │
└─────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────┐
│ 4. ANALYZE & GROUP TYPES │
│ • Extract DataContract Name and Namespace │
│ • Parse namespace: "LBS.Foundry.Queries.SuperCoach" │
│ → Module: "superCoach" │
│ • Group queries by module (core, sport, superCoach, etc.) │
│ • Group commands by module │
│ • Build type dependency graph │
└─────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────┐
│ 5. GENERATE TYPESCRIPT FILES │
│ Generated files: │
│ • src/generated/types/queries.ts (all query interfaces) │
│ • src/generated/types/commands.ts (all command interfaces) │
│ • src/generated/types/responses.ts (all response interfaces) │
│ • src/generated/queries.ts (query builder object) │
│ • src/generated/commands.ts (command builder object) │
│ • src/generated/constants.ts (RoleDefinition, etc.) │
│ • src/generated/flat.ts (flat exports) │
└─────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────┐
│ 6. BUILD NPM PACKAGE │
│ cd sdk/typescript │
│ npm install │
│ npm run build (TypeScript → JavaScript + .d.ts files) │
│ npm run test (Run tests against generated code) │
│ → Produces: dist/ folder with compiled JS + type definitions │
└─────────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────────┐
│ 7. PUBLISH TO GITHUB PACKAGES (PRIVATE) │
│ npm publish │
│ → Published: @your-github-org/lbs-foundry-typescript-sdk@... │
│ → Accessible only to GitHub org members with valid token │
└─────────────────────────────────────────────────────────────────────┘
Detailed Generator Implementation¶
Tasks:
- Create console app
LBS.Tools.TypeScriptGenerator:
// Program.cs
public class Program
{
public static async Task Main(string[] args)
{
var config = ParseArgs(args);
// Step 1: Scan assemblies
var assemblies = LoadAssemblies(config.AssemblyPaths);
// Step 2: Extract types
var scanner = new TypeScanner();
var queries = scanner.FindQueries(assemblies);
var commands = scanner.FindCommands(assemblies);
var responseContracts = scanner.FindResponseContracts(assemblies);
// Step 3: Generate TypeScript
var generator = new TypeScriptGenerator(config);
await generator.GenerateTypesAsync(queries, commands, responseContracts);
await generator.GenerateBuildersAsync(queries, commands);
await generator.GenerateConstantsAsync(assemblies);
Console.WriteLine($"Generated TypeScript SDK at {config.OutputPath}");
}
}
- Use Roslyn/MetadataLoadContext to scan assemblies:
public class TypeScanner
{
public List<TypeInfo> FindQueries(IEnumerable<Assembly> assemblies)
{
var queries = new List<TypeInfo>();
foreach (var assembly in assemblies)
{
// Find all types with [DataContract] that implement IQuery interfaces
var types = assembly.GetTypes()
.Where(t => t.GetCustomAttribute<DataContractAttribute>() != null)
.Where(t => t.GetInterfaces().Any(i =>
i.Name == "IQuery" ||
i.Name == "IPublicQuery" ||
i.Name == "ISecureQuery"))
.ToList();
foreach (var type in types)
{
var typeInfo = AnalyzeType(type);
queries.Add(typeInfo);
}
}
return queries;
}
private TypeInfo AnalyzeType(Type type)
{
// Extract DataContract attribute
var dataContract = type.GetCustomAttribute<DataContractAttribute>();
// Extract RequiresRoles attribute
var requiresRoles = type.GetCustomAttribute<RequiresRolesAttribute>();
// Extract properties
var properties = type.GetProperties()
.Where(p => p.GetCustomAttribute<DataMemberAttribute>() != null)
.Select(p => new PropertyInfo
{
Name = p.Name,
Type = p.PropertyType,
IsOptional = IsNullable(p.PropertyType),
XmlDoc = GetXmlDoc(p)
})
.ToList();
return new TypeInfo
{
Name = dataContract?.Name ?? type.Name,
Namespace = dataContract?.Namespace ?? "LBS.Foundry",
Properties = properties,
RequiredRoles = requiresRoles?.Roles ?? Array.Empty<string>(),
XmlDoc = GetXmlDoc(type),
IsQuery = IsQueryType(type),
IsCommand = IsCommandType(type)
};
}
}
- Build type mapping engine:
public class TypeMapper
{
public string MapToTypeScript(Type csharpType, bool isOptional)
{
var typeScriptType = csharpType switch
{
_ when csharpType == typeof(string) => "string",
_ when csharpType == typeof(int) => "number",
_ when csharpType == typeof(long) => "number",
_ when csharpType == typeof(decimal) => "number",
_ when csharpType == typeof(double) => "number",
_ when csharpType == typeof(float) => "number",
_ when csharpType == typeof(bool) => "boolean",
_ when csharpType == typeof(Guid) => "string",
_ when csharpType == typeof(DateTime) => "string",
_ when csharpType == typeof(DateTimeOffset) => "string",
// Nullable types
_ when IsNullable(csharpType) =>
MapToTypeScript(Nullable.GetUnderlyingType(csharpType)!, true),
// Arrays and Lists
_ when IsEnumerable(csharpType) =>
$"{MapToTypeScript(GetElementType(csharpType), false)}[]",
// Complex types (other contracts)
_ => csharpType.Name
};
return isOptional ? $"{typeScriptType} | undefined" : typeScriptType;
}
private bool IsNullable(Type type) =>
Nullable.GetUnderlyingType(type) != null ||
!type.IsValueType;
}
- Generate TypeScript interfaces:
public class TypeScriptGenerator
{
public async Task GenerateTypesAsync(
List<TypeInfo> queries,
List<TypeInfo> commands,
List<TypeInfo> responses)
{
// Generate query interfaces
var queryContent = new StringBuilder();
queryContent.AppendLine("// Auto-generated query types");
queryContent.AppendLine("// DO NOT EDIT - Changes will be overwritten\n");
foreach (var query in queries)
{
queryContent.AppendLine(GenerateInterface(query));
}
await File.WriteAllTextAsync(
Path.Combine(config.OutputPath, "types", "queries.ts"),
queryContent.ToString()
);
// Similar for commands and responses...
}
private string GenerateInterface(TypeInfo typeInfo)
{
var sb = new StringBuilder();
// Add JSDoc from XML comments
if (!string.IsNullOrEmpty(typeInfo.XmlDoc))
{
sb.AppendLine("/**");
sb.AppendLine($" * {typeInfo.XmlDoc}");
if (typeInfo.RequiredRoles.Any())
{
sb.AppendLine($" * @requires {string.Join(", ", typeInfo.RequiredRoles)}");
}
sb.AppendLine(" */");
}
sb.AppendLine($"export interface {typeInfo.Name} {{");
foreach (var prop in typeInfo.Properties)
{
// Add property JSDoc
if (!string.IsNullOrEmpty(prop.XmlDoc))
{
sb.AppendLine($" /** {prop.XmlDoc} */");
}
// Generate property
var optional = prop.IsOptional ? "?" : "";
var tsType = typeMapper.MapToTypeScript(prop.Type, prop.IsOptional);
sb.AppendLine($" {ToCamelCase(prop.Name)}{optional}: {tsType};");
}
sb.AppendLine("}");
return sb.ToString();
}
}
- Generate builder objects (queries & commands):
public async Task GenerateBuildersAsync(List<TypeInfo> queries, List<TypeInfo> commands)
{
// Group by module
var queryModules = queries
.GroupBy(q => ExtractModule(q.Namespace))
.OrderBy(g => g.Key);
var sb = new StringBuilder();
sb.AppendLine("// Auto-generated query builders");
sb.AppendLine("import * as Types from './types/queries';");
sb.AppendLine();
sb.AppendLine("export const queries = {");
foreach (var module in queryModules)
{
sb.AppendLine($" {module.Key}: {{");
foreach (var query in module)
{
var builderName = ToCamelCase(query.Name.Replace("Query", ""));
var hasParams = query.Properties.Any(p => p.Name != "QueryType");
if (hasParams)
{
sb.AppendLine($" {builderName}: (params: Omit<Types.{query.Name}, 'queryType'>): Types.{query.Name} => ({{");
sb.AppendLine($" queryType: '{query.Name.Replace("Query", "")}',");
sb.AppendLine($" ...params");
sb.AppendLine($" }}),");
}
else
{
sb.AppendLine($" {builderName}: (): Types.{query.Name} => ({{");
sb.AppendLine($" queryType: '{query.Name.Replace("Query", "")}'");
sb.AppendLine($" }}),");
}
}
sb.AppendLine(" },");
}
sb.AppendLine("} as const;");
await File.WriteAllTextAsync(
Path.Combine(config.OutputPath, "queries.ts"),
sb.ToString()
);
}
private string ExtractModule(string dataContractNamespace)
{
// "LBS.Foundry.Queries.SuperCoach" → "superCoach"
var parts = dataContractNamespace.Split('.');
if (parts.Length >= 4)
{
return ToCamelCase(parts[3]); // "SuperCoach" → "superCoach"
}
return "misc";
}
- Preserve XML documentation as JSDoc:
public string GetXmlDoc(MemberInfo member)
{
// Load XML documentation file
var xmlPath = Path.ChangeExtension(member.Module.Assembly.Location, ".xml");
if (!File.Exists(xmlPath)) return string.Empty;
var xml = XDocument.Load(xmlPath);
var memberName = GetXmlMemberName(member);
var summary = xml.Descendants("member")
.FirstOrDefault(m => m.Attribute("name")?.Value == memberName)
?.Element("summary")
?.Value
.Trim();
return summary ?? string.Empty;
}
Example Generated Output¶
Input (C#):
[DataContract(Name = "PlayerSearch", Namespace = LbsNamespace.SuperCoachQueries)]
public sealed class PlayerSearchQuery : IPublicQuery
{
[DataMember]
public List<string>? Teams { get; set; }
[DataMember]
public int? MinPrice { get; set; }
}
Output (TypeScript):
// types/queries.ts
export interface PlayerSearchQuery {
queryType: 'PlayerSearch';
teams?: string[];
minPrice?: number;
}
// queries.ts
export const queries = {
superCoach: {
playerSearch: (params: Omit<PlayerSearchQuery, 'queryType'>): PlayerSearchQuery => ({
queryType: 'PlayerSearch',
...params
})
}
} as const;
Example Output - Queries:
/**
* Query to search for players with optional filters
* Implements: IPublicQuery, IRequireUserContext
*/
export interface PlayerSearchQuery {
/** The query type discriminator */
queryType: 'PlayerSearch';
/** Team names to filter by */
teams?: string[];
/** Search term for player name */
searchTerm?: string;
/** Minimum player price */
minPrice?: number;
/** Maximum player price */
maxPrice?: number;
/** Position filters */
positions?: string[];
}
/**
* Result contract for player search
*/
export interface PlayerSearchResult {
id: string;
displayName: string;
teamName: string;
position: string;
price: number;
}
Example Output - Commands:
/**
* Command to create a new user
* Implements: DomainCommand<UserId>
* Requires Roles: UserManager, Admin
*/
export interface CreateUserCommand {
/** Command type discriminator */
commandType: 'CreateUser';
/** Aggregate root ID */
aggregateRootId?: string;
/** User email address */
email: string;
/** User first name */
firstName?: string;
/** User last name */
lastName?: string;
/** Clerk user ID for authentication */
clerkUserId?: string;
/** Username */
userName?: string;
/** Email verification status */
emailVerified: boolean;
/** Account creation timestamp */
createdAt: string;
/** User status (Active, Inactive, Suspended, Deleted) */
status: UserStatus;
/** Assigned roles */
roles: string[];
/** Account type (Human, ServiceAccount) */
userType: AccountType;
/** Password for service accounts */
password?: string;
}
/**
* Command to update participant slug name
* Implements: DomainCommand<ParticipantId>
* Requires Roles: Admin
*/
export interface UpdateParticipantSlugNameCommand {
commandType: 'UpdateParticipantSlugName';
aggregateRootId: string;
slugName: string;
}
/**
* Result of command execution
*/
export interface CommandResponse<TResult = void> {
success: boolean;
aggregateRootId?: string;
result?: TResult;
events?: DomainEvent[];
errors?: CommandError[];
}
Phase 2: Client Library (Week 2)¶
Goal: Build lightweight, type-safe client wrapper that completely abstracts HTTP details
Architecture: Developers never manually construct HTTP requests - they only call client methods.
Tasks:
-
Create client configuration:
-
Implement query executor (handles all
/api/readmodelcommunication):export class FoundryQueryClient { private readonly config: FoundryClientConfig; private readonly httpClient: HttpClient; constructor(config: FoundryClientConfig) { this.config = config; this.httpClient = new HttpClient(config); } /** * Execute a query against /api/readmodel * Handles auth headers, error handling, response parsing automatically */ async query<TQuery, TResult>( query: TQuery, options?: QueryOptions ): Promise<ReadModelResponse<TResult>> { try { // POST to /api/readmodel const response = await this.httpClient.post('/api/readmodel', { query, skip: options?.skip ?? 0, take: options?.take ?? 50, orderBy: options?.orderBy }); return { data: response.data.data as TResult[], totalCount: response.data.totalCount, skip: response.data.skip, take: response.data.take }; } catch (error) { this.handleError(error); throw error; } } /** * Execute a query with pagination support */ async queryPaginated<TQuery, TResult>( query: TQuery, pagination: PaginationOptions ): Promise<PaginatedResponse<TResult>> { return this.query<TQuery, TResult>(query, pagination); } private handleError(error: any): void { // Centralized error handling if (this.config.onError) { this.config.onError(error); } } } -
Implement command executor (handles all
/api/commandcommunication):export class FoundryCommandClient { private readonly config: FoundryClientConfig; private readonly httpClient: HttpClient; constructor(config: FoundryClientConfig) { this.config = config; this.httpClient = new HttpClient(config); } /** * Execute a command against /api/command * Handles auth headers, error handling, response parsing automatically */ async execute<TCommand, TResult = void>( command: TCommand ): Promise<CommandResponse<TResult>> { try { // POST to /api/command const response = await this.httpClient.post('/api/command', command); return { success: response.data.success ?? true, aggregateRootId: response.data.aggregateRootId, result: response.data.result as TResult, events: response.data.events, errors: response.data.errors }; } catch (error) { this.handleError(error); throw error; } } private handleError(error: any): void { if (this.config.onError) { this.config.onError(error); } } } -
Create unified client (single entry point for all API calls):
/** * Main client for LBS Foundry API * Abstracts all HTTP communication - developers never construct requests manually */ export class FoundryClient { // Sub-clients for queries and commands private readonly queryClient: FoundryQueryClient; private readonly commandClient: FoundryCommandClient; constructor(config: FoundryClientConfig) { this.queryClient = new FoundryQueryClient(config); this.commandClient = new FoundryCommandClient(config); } /** * Execute a query * @example * const result = await client.query(queries.superCoach.playerSearch({ minPrice: 200000 })); */ async query<TQuery, TResult>( query: TQuery, options?: QueryOptions ): Promise<ReadModelResponse<TResult>> { return this.queryClient.query<TQuery, TResult>(query, options); } /** * Execute a command * @example * const result = await client.execute(commands.core.createUser({ email: '...' })); */ async execute<TCommand, TResult = void>( command: TCommand ): Promise<CommandResponse<TResult>> { return this.commandClient.execute<TCommand, TResult>(command); } /** * Execute a query with pagination */ async queryPaginated<TQuery, TResult>( query: TQuery, pagination: PaginationOptions ): Promise<PaginatedResponse<TResult>> { return this.queryClient.queryPaginated<TQuery, TResult>(query, pagination); } } -
Internal HTTP client (handles auth, retries, etc.):
class HttpClient { private readonly config: FoundryClientConfig; private readonly axiosInstance: AxiosInstance; constructor(config: FoundryClientConfig) { this.config = config; this.axiosInstance = axios.create({ baseURL: config.baseUrl, timeout: config.timeout ?? 30000, headers: this.buildHeaders() }); this.setupInterceptors(); } private buildHeaders(): Record<string, string> { const headers: Record<string, string> = { 'Content-Type': 'application/json' }; if (this.config.auth?.type === 'bearer' && this.config.auth.token) { headers['Authorization'] = `Bearer ${this.config.auth.token}`; } else if (this.config.auth?.type === 'basic') { const credentials = btoa(`${this.config.auth.username}:${this.config.auth.password}`); headers['Authorization'] = `Basic ${credentials}`; } return headers; } private setupInterceptors(): void { // Request interceptor this.axiosInstance.interceptors.request.use( (config) => { // Add any request modifications here return config; }, (error) => Promise.reject(error) ); // Response interceptor this.axiosInstance.interceptors.response.use( (response) => response, async (error) => { // Retry logic if (this.config.retries && this.shouldRetry(error)) { return this.retry(error.config); } return Promise.reject(error); } ); } async post<T = any>(url: string, data: any): Promise<AxiosResponse<T>> { return this.axiosInstance.post<T>(url, data); } private shouldRetry(error: any): boolean { // Retry on network errors or 5xx server errors return !error.response || (error.response.status >= 500 && error.response.status < 600); } private async retry(config: any, attemptNumber: number = 1): Promise<any> { if (attemptNumber > (this.config.retries ?? 3)) { throw new Error('Max retries exceeded'); } // Exponential backoff const delay = Math.min(1000 * Math.pow(2, attemptNumber), 10000); await new Promise(resolve => setTimeout(resolve, delay)); return this.axiosInstance.request(config); } }
Complete Usage Example (developers never see HTTP details):
import { FoundryClient, queries, commands } from '@lbs-foundry/typescript-sdk';
// 1. Initialize client ONCE (typically in a singleton or context)
const client = new FoundryClient({
baseUrl: 'https://api.ballr.live',
auth: {
type: 'bearer',
token: 'your-jwt-token'
},
timeout: 30000,
retries: 3,
onError: (error) => {
console.error('API Error:', error);
// Send to error tracking service
}
});
// 2. Execute queries - NO manual HTTP calls!
const players = await client.query(
queries.superCoach.playerSearch({
teams: ['Panthers'],
minPrice: 200000
})
);
console.log('Found players:', players.data);
console.log('Total:', players.totalCount);
// 3. Execute commands - NO manual HTTP calls!
const result = await client.execute(
commands.core.createUser({
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
emailVerified: true,
createdAt: new Date().toISOString(),
status: UserStatus.Active,
roles: [RoleDefinition.Member],
userType: AccountType.Human
})
);
console.log('Created user:', result.aggregateRootId);
// 4. Pagination support - NO manual HTTP calls!
const paginatedPlayers = await client.queryPaginated(
queries.superCoach.playerSearch({ minPrice: 200000 }),
{ skip: 0, take: 20, orderBy: 'displayName' }
);
React Integration Example:
// Create client instance (typically in a context or hook)
const foundryClient = new FoundryClient({
baseUrl: process.env.REACT_APP_API_URL!,
auth: { type: 'bearer', token: authToken }
});
// Use with React Query - developers only call client methods
function PlayerList() {
const { data, isLoading } = useQuery({
queryKey: ['players', 'search'],
queryFn: () => foundryClient.query(
queries.superCoach.playerSearch({ minPrice: 200000 })
)
});
// Use with mutations
const createUserMutation = useMutation({
mutationFn: (userData: CreateUserData) =>
foundryClient.execute(
commands.core.createUser(userData)
)
});
// All HTTP details handled by the client!
return <div>...</div>;
}
Key Benefits of Client Executors:
- Zero HTTP Boilerplate - Developers never write
fetchoraxioscalls - Centralized Auth - Configure once, works everywhere
- Automatic Retries - Built-in retry logic with exponential backoff
- Type Safety - Request and response types fully typed
- Error Handling - Centralized error handling and logging
- Testing - Easy to mock
FoundryClientfor tests - Consistency - All API calls follow the same pattern
Phase 3: Developer Experience Enhancements (Week 2)¶
Goal: Maximize type safety and IntelliSense
Tasks:
- Design Namespacing Strategy:
The SDK will use the [DataContract] attribute's Name and Namespace properties as the source of truth for TypeScript namespace generation. This gives backend developers explicit control over the client-side API structure.
C# Namespace Declaration - Aligned with ModuleDefinition:
Start simple by aligning with your existing ModuleDefinition (Core, SportCore, SuperCoachNrl, BallrContests, etc.):
// LBS.Domain.Core/LbsNamespace.cs
public static class LbsNamespace
{
public const string Default = "LBS.Foundry";
// Query Namespaces - Aligned with ModuleDefinition
public const string CoreQueries = "LBS.Foundry.Queries.Core";
public const string SportQueries = "LBS.Foundry.Queries.Sport";
public const string SuperCoachQueries = "LBS.Foundry.Queries.SuperCoach";
public const string BallrQueries = "LBS.Foundry.Queries.Ballr";
public const string DiscordQueries = "LBS.Foundry.Queries.Discord";
// Command Namespaces - Aligned with ModuleDefinition
public const string CoreCommands = "LBS.Foundry.Commands.Core";
public const string SportCommands = "LBS.Foundry.Commands.Sport";
public const string SuperCoachCommands = "LBS.Foundry.Commands.SuperCoach";
public const string BallrCommands = "LBS.Foundry.Commands.Ballr";
public const string DiscordCommands = "LBS.Foundry.Commands.Discord";
}
Module Mapping:
- ModuleDefinition.Core → LbsNamespace.CoreQueries/Commands
- ModuleDefinition.SportCore → LbsNamespace.SportQueries/Commands
- ModuleDefinition.SuperCoachNrl → LbsNamespace.SuperCoachQueries/Commands
- ModuleDefinition.BallrContests → LbsNamespace.BallrQueries/Commands
- ModuleDefinition.Discord → LbsNamespace.DiscordQueries/Commands
Apply to Queries and Commands:
// SuperCoach queries
[DataContract(Name = "PlayerSearch", Namespace = LbsNamespace.SuperCoachQueries)]
public sealed class PlayerSearchQuery : IPublicQuery, IRequireUserContext
{
public string QueryType => this.GetType().GetQueryTypeName();
// ...
}
[DataContract(Name = "PlayerComparison", Namespace = LbsNamespace.SuperCoachQueries)]
public sealed class PlayerComparisonQuery : IPublicQuery, IRequireUserContext { }
// Core queries
[DataContract(Name = "CurrentUser", Namespace = LbsNamespace.CoreQueries)]
public class CurrentUserQuery : ISecureQuery, IRequireUserContext { }
// Sport queries
[DataContract(Name = "GetCompetitionBySport", Namespace = LbsNamespace.SportQueries)]
public class GetCompetitionBySportQuery : IPublicQuery { }
// Core commands
[DataContract(Name = "CreateUser", Namespace = LbsNamespace.CoreCommands)]
[RequiresRoles(RoleDefinition.UserManager, RoleDefinition.Admin)]
public sealed record CreateUserCommand : DomainCommand<UserId> { }
// Sport commands
[DataContract(Name = "CreateTeam", Namespace = LbsNamespace.SportCommands)]
public sealed record CreateTeamCommand : DomainCommand<TeamId> { }
// Ballr commands
[DataContract(Name = "CreateContest", Namespace = LbsNamespace.BallrCommands)]
public sealed record CreateContestCommand : DomainCommand<ContestId> { }
TypeScript Generation Logic:
The generator parses the namespace string to build the hierarchy:
// In the generator tool
string dataContractNamespace = GetDataContractNamespace(type); // "LBS.Foundry.Queries.Ballr.SuperCoach"
string[] parts = dataContractNamespace.Split('.');
// Extract the relevant parts after "LBS.Foundry"
// "LBS.Foundry.Queries.Ballr.SuperCoach" → ["Queries", "Ballr", "SuperCoach"]
var namespaceParts = parts.Skip(2).ToArray();
// Determine if it's a query or command
bool isQuery = namespaceParts[0] == "Queries";
bool isCommand = namespaceParts[0] == "Commands";
// Extract the path: ["Ballr", "SuperCoach"]
var path = namespaceParts.Skip(1).Select(CamelCase).ToArray(); // ["ballr", "superCoach"]
// Generate TypeScript namespace
// queries.ballr.superCoach.playerSearch
Generated TypeScript Structure (Modern ES Module Pattern):
Modern TypeScript/JavaScript Preference: Use object literals instead of the legacy namespace keyword.
// MODERN APPROACH - Object literals with ES modules
// File: src/generated/queries.ts
// Auto-generated from DataContract Namespace attributes
export const queries = {
// Core module queries (ModuleDefinition.Core → LbsNamespace.CoreQueries)
core: {
currentUser: (): CurrentUserQuery => ({ queryType: 'CurrentUser' }),
discordVerificationStatus: (params: Omit<DiscordVerificationStatusQuery, 'queryType'>): DiscordVerificationStatusQuery => ({
queryType: 'DiscordVerificationStatus',
...params
}),
},
// Sport module queries (ModuleDefinition.SportCore → LbsNamespace.SportQueries)
sport: {
getCompetitionBySport: (params: Omit<GetCompetitionBySportQuery, 'queryType'>): GetCompetitionBySportQuery => ({
queryType: 'GetCompetitionBySport',
...params
}),
getGroups: (params: Omit<GetGroupsQuery, 'queryType'>): GetGroupsQuery => ({
queryType: 'GetGroups',
...params
}),
},
// SuperCoach module queries (ModuleDefinition.SuperCoachNrl → LbsNamespace.SuperCoachQueries)
superCoach: {
playerSearch: (params: Omit<PlayerSearchQuery, 'queryType'>): PlayerSearchQuery => ({
queryType: 'PlayerSearch',
...params
}),
playerComparison: (params: Omit<PlayerComparisonQuery, 'queryType'>): PlayerComparisonQuery => ({
queryType: 'PlayerComparison',
...params
}),
},
// Ballr module queries (ModuleDefinition.BallrContests → LbsNamespace.BallrQueries)
ballr: {
listContests: (params: Omit<BallrContestsQuery, 'queryType'>): BallrContestsQuery => ({
queryType: 'BallrContests',
...params
}),
getContestById: (params: Omit<GetBallrContestByIdQuery, 'queryType'>): GetBallrContestByIdQuery => ({
queryType: 'GetBallrContestById',
...params
}),
},
// Discord module queries (ModuleDefinition.Discord → LbsNamespace.DiscordQueries)
discord: {
// Discord queries here
},
} as const;
// File: src/generated/commands.ts
export const commands = {
// Core module commands
core: {
createUser: (params: Omit<CreateUserCommand, 'commandType'>): CreateUserCommand => ({
commandType: 'CreateUser',
...params
}),
updateUser: (params: Omit<UpdateUserCommand, 'commandType'>): UpdateUserCommand => ({
commandType: 'UpdateUser',
...params
}),
deleteUser: (params: Omit<DeleteUserCommand, 'commandType'>): DeleteUserCommand => ({
commandType: 'DeleteUser',
...params
}),
},
// Sport module commands
sport: {
createTeam: (params: Omit<CreateTeamCommand, 'commandType'>): CreateTeamCommand => ({
commandType: 'CreateTeam',
...params
}),
createParticipant: (params: Omit<CreateParticipantCommand, 'commandType'>): CreateParticipantCommand => ({
commandType: 'CreateParticipant',
...params
}),
createSportingEvent: (params: Omit<CreateSportingEventCommand, 'commandType'>): CreateSportingEventCommand => ({
commandType: 'CreateSportingEvent',
...params
}),
},
// SuperCoach module commands
superCoach: {
setPlayerStats: (params: Omit<SetSuperCoachParticipantFantasyStatsCommand, 'commandType'>): SetSuperCoachParticipantFantasyStatsCommand => ({
commandType: 'SetSuperCoachParticipantFantasyStats',
...params
}),
},
// Ballr module commands
ballr: {
createContest: (params: Omit<CreateContestCommand, 'commandType'>): CreateContestCommand => ({
commandType: 'CreateContest',
...params
}),
},
// Discord module commands
discord: {
// Discord commands here
},
} as const;
Why Object Literals Instead of namespace Keyword?
| Pattern | Status | Reason |
|---|---|---|
namespace keyword |
Legacy | TypeScript-specific, pre-ES6 solution |
| Object literals | Modern | Standard JavaScript, works everywhere, tree-shakeable |
// OLD WAY (TypeScript namespaces - legacy)
export namespace queries {
export namespace core {
export const currentUser = () => ({ ... });
}
}
// NEW WAY (Object literals - modern)
export const queries = {
core: {
currentUser: () => ({ ... })
}
};
Benefits of Object Literal Approach: 1. Standard JavaScript - Works in any JS environment, not just TypeScript 2. Better Tree-Shaking - Bundlers can eliminate unused code more easily 3. Simpler Syntax - Less TypeScript-specific syntax to learn 4. Runtime Value - Can be inspected, iterated, and manipulated at runtime 5. Industry Standard - Modern libraries use this pattern (React Query, Zustand, etc.)
Usage Examples:
import { queries, commands } from '@lbs-foundry/typescript-sdk';
// Simple, module-aligned access
const players = await client.query(
queries.superCoach.playerSearch({ minPrice: 200000 })
);
const user = await client.execute(
commands.core.createUser({
email: 'user@example.com',
status: UserStatus.Active,
roles: [RoleDefinition.Member],
userType: AccountType.Human,
emailVerified: false,
createdAt: new Date().toISOString(),
})
);
const team = await client.execute(
commands.sport.createTeam({
displayName: 'Panthers',
// ...
})
);
// IntelliSense shows you all available modules
queries. // → { core, sport, superCoach, ballr, discord }
queries.sport. // → { getCompetitionBySport, getGroups, ... }
Flat Access Alternative (for convenience):
// Also export flat structure for backward compatibility and convenience
export const flatQueries = {
// Core
currentUser: queries.core.user.current,
// Sport
getCompetitionBySport: queries.sport.competition.getBySport,
// Ballr
playerSearch: queries.ballr.superCoach.playerSearch,
playerComparison: queries.ballr.superCoach.playerComparison,
// ... all queries flattened
};
export const flatCommands = {
// Core
createUser: commands.core.user.create,
updateUser: commands.core.user.update,
// Sport
createTeam: commands.sport.team.create,
createSportingEvent: commands.sport.sportingEvent.create,
// ... all commands flattened
};
Benefits of Using DataContract Namespaces: 1. Explicit Control - Backend developers explicitly declare client-side structure 2. Decoupled from C# Organization - Can reorganize C# namespaces without breaking clients 3. Part of Serialization Contract - Already used by WCF/DataContractSerializer 4. Stable Public API - Changes to internal structure don't affect client SDK 5. Intentional Design - Forces consideration of client-side developer experience 6. Easy Refactoring - Move queries between domains by changing one attribute
Benefits of Hierarchical Namespacing:
1. Discoverability - Browse related queries/commands by domain
2. IntelliSense - Type ahead through the namespace hierarchy
3. No Collisions - Multiple create commands can coexist
4. Tree Shaking - Import only what you need
5. Logical Grouping - Mirrors backend domain structure
6. Flexibility - Both namespaced and flat access patterns
Example: Reorganizing Without Breaking Clients:
// Before: Query is in Sport module
[DataContract(Name = "PlayerSearch", Namespace = LbsNamespace.SportQueries)]
public class PlayerSearchQuery : IQuery { }
// C# file location: LBS.Domain.Sport/Queries/PlayerSearchQuery.cs
// Client uses: queries.sport.playerSearch({ ... })
// Later: Move to SuperCoach module for better organization
[DataContract(Name = "PlayerSearch", Namespace = LbsNamespace.SuperCoachQueries)]
public class PlayerSearchQuery : IQuery { }
// C# file location: LBS.Ballr.Core/SuperCoach/Queries/PlayerSearchQuery.cs
// Client uses: queries.superCoach.playerSearch({ ... })
// ^ Different C# namespace, client API changed intentionally via attribute
// The DataContract Namespace controls client API, not C# namespace!
// You can move files around in C# without affecting clients if you keep
// the DataContract Namespace the same.
Package Exports Strategy:
// Main entry point: src/index.ts
export { FoundryClient } from './client';
export { queries, commands } from './generated';
export { flatQueries, flatCommands } from './generated/flat';
// Module-specific exports for targeted imports (tree-shakeable)
export * as coreQueries from './generated/queries/core';
export * as sportQueries from './generated/queries/sport';
export * as superCoachQueries from './generated/queries/superCoach';
export * as ballrQueries from './generated/queries/ballr';
export * as discordQueries from './generated/queries/discord';
export * as coreCommands from './generated/commands/core';
export * as sportCommands from './generated/commands/sport';
export * as superCoachCommands from './generated/commands/superCoach';
export * as ballrCommands from './generated/commands/ballr';
export * as discordCommands from './generated/commands/discord';
// Type exports (all generated types)
export * from './types';
// Constants (RoleDefinition, UserStatus, etc.)
export * from './generated/constants';
Advanced Import Patterns:
// Import everything (most common)
import { queries, commands } from '@lbs-foundry/typescript-sdk';
const players = await client.query(queries.superCoach.playerSearch({ ... }));
// Import specific module only (tree-shakeable)
import { superCoachQueries } from '@lbs-foundry/typescript-sdk';
const players = await client.query(superCoachQueries.playerSearch({ ... }));
// Import flat for quick migration
import { flatQueries, flatCommands } from '@lbs-foundry/typescript-sdk';
const players = await client.query(flatQueries.playerSearch({ ... }));
// Mix and match styles
import { queries, flatCommands, superCoachQueries } from '@lbs-foundry/typescript-sdk';
// Import just types (for typing your own functions)
import type { PlayerSearchQuery, CreateUserCommand } from '@lbs-foundry/typescript-sdk';
Visual Comparison - IntelliSense Experience:
// WITHOUT module grouping (flat structure)
import { queries } from '@lbs-foundry/typescript-sdk';
queries. // IntelliSense shows 100+ items:
// playerSearch, currentUser, playerComparison, getCompetitionBySport,
// listContests, getContestById, getGroups, discordVerificationStatus,
// ... overwhelming!
// WITH module grouping (object literal structure)
import { queries } from '@lbs-foundry/typescript-sdk';
queries. // IntelliSense shows 5-7 modules:
// ├─ core (ModuleDefinition.Core)
// ├─ sport (ModuleDefinition.SportCore)
// ├─ superCoach (ModuleDefinition.SuperCoachNrl)
// ├─ ballr (ModuleDefinition.BallrContests)
// └─ discord (ModuleDefinition.Discord)
queries.superCoach. // Shows all SuperCoach queries:
// ├─ playerSearch
// ├─ playerComparison
// ├─ playerRoundSearch
// └─ profileBySlug
queries.sport. // Shows all Sport queries:
// ├─ getCompetitionBySport
// ├─ getGroups
// └─ ...
// Clear, organized, aligned with your module structure!
Migration Path:
For teams already using manual types, the SDK provides both patterns:
// Step 1: Install SDK alongside existing code
pnpm add @lbs-foundry/typescript-sdk
// Step 2: Start using flat exports (drop-in replacement)
import { flatQueries, flatCommands } from '@lbs-foundry/typescript-sdk';
const query = flatQueries.playerSearch({ ... }); // Same as before!
const command = flatCommands.createUser({ ... });
// Step 3: Gradually migrate to module-grouped structure
import { queries, commands } from '@lbs-foundry/typescript-sdk';
const query = queries.superCoach.playerSearch({ ... }); // Organized by module
const command = commands.core.createUser({ ... });
// Step 4: Remove manual type definitions
// Delete old manually-defined types, rely on SDK
// Old: src/types/queries.ts DELETE
// Old: src/types/commands.ts DELETE
// New: import from '@lbs-foundry/typescript-sdk'
- Namespace Extraction from DataContract Attribute:
// In the C# generator tool (using Roslyn)
public class NamespaceExtractor
{
public TypeScriptNamespace ExtractNamespace(INamedTypeSymbol typeSymbol)
{
// Get DataContract attribute
var dataContractAttr = typeSymbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.Name == "DataContractAttribute");
if (dataContractAttr == null)
{
throw new Exception($"Type {typeSymbol.Name} missing [DataContract] attribute");
}
// Extract Name property
var name = dataContractAttr.NamedArguments
.FirstOrDefault(arg => arg.Key == "Name")
.Value.Value?.ToString();
// Extract Namespace property
var namespaceValue = dataContractAttr.NamedArguments
.FirstOrDefault(arg => arg.Key == "Namespace")
.Value.Value?.ToString();
if (string.IsNullOrEmpty(namespaceValue))
{
// Fallback: use LbsNamespace.Default if not specified
namespaceValue = "LBS.Foundry";
}
return ParseNamespace(namespaceValue, name);
}
private TypeScriptNamespace ParseNamespace(string dataContractNamespace, string typeName)
{
// Example: "LBS.Foundry.Queries.Ballr.SuperCoach"
var parts = dataContractNamespace.Split('.');
if (parts.Length < 3 || parts[0] != "LBS" || parts[1] != "Foundry")
{
// Unrecognized format, put in root
return new TypeScriptNamespace { Path = new[] { "misc" }, Name = typeName };
}
// Skip "LBS.Foundry"
var relevantParts = parts.Skip(2).ToArray();
if (relevantParts.Length == 0)
{
// Just "LBS.Foundry", no category
return new TypeScriptNamespace { Path = new[] { "root" }, Name = typeName };
}
// First part should be "Queries" or "Commands"
var category = relevantParts[0]; // "Queries" or "Commands"
if (category != "Queries" && category != "Commands")
{
// Unknown category
return new TypeScriptNamespace { Path = new[] { "misc" }, Name = typeName };
}
// Remaining parts form the path: ["Ballr", "SuperCoach"]
var path = relevantParts.Skip(1)
.Select(ToCamelCase)
.ToArray();
return new TypeScriptNamespace
{
Category = category.ToLower(), // "queries" or "commands"
Path = path, // ["ballr", "superCoach"]
Name = ToCamelCase(typeName) // "playerSearch"
};
}
private string ToCamelCase(string pascalCase)
{
if (string.IsNullOrEmpty(pascalCase)) return pascalCase;
return char.ToLowerInvariant(pascalCase[0]) + pascalCase.Substring(1);
}
}
// Usage in generator
var namespaceInfo = extractor.ExtractNamespace(queryType);
// namespaceInfo.Category = "queries"
// namespaceInfo.Path = ["ballr", "superCoach"]
// namespaceInfo.Name = "playerSearch"
// Generated: queries.ballr.superCoach.playerSearch
Fallback Handling:
// If a type has no Namespace specified, use intelligent defaults
if (string.IsNullOrEmpty(dataContractNamespace))
{
// Inspect C# namespace as fallback
var csharpNamespace = typeSymbol.ContainingNamespace.ToDisplayString();
if (csharpNamespace.Contains("LBS.Ballr"))
return "LBS.Foundry.Queries.Ballr";
else if (csharpNamespace.Contains("LBS.Domain.Core"))
return "LBS.Foundry.Queries.Core";
else if (csharpNamespace.Contains("LBS.Domain.Sport"))
return "LBS.Foundry.Queries.Sport";
else if (csharpNamespace.Contains("LBS.Domain.Fantasy"))
return "LBS.Foundry.Queries.Fantasy";
else
return "LBS.Foundry"; // Root level
}
-
Generate Namespaced Builders (see namespacing strategy above for full structure)
-
Generate Flat Builders (for backward compatibility):
// Flat structure - simple object with all builders export const flatQueries = { // Auto-generated from all query types currentUser: queries.core.user.current, playerSearch: queries.ballr.superCoach.playerSearch, playerComparison: queries.ballr.superCoach.playerComparison, getCompetitionBySport: queries.sport.competition.getBySport, // ... all queries }; export const flatCommands = { // Auto-generated from all command types createUser: commands.core.user.create, updateUser: commands.core.user.update, createTeam: commands.sport.team.create, updateParticipantSlugName: commands.sport.participant.updateSlugName, // ... all commands }; // Usage - simple and familiar const query = flatQueries.playerSearch({ teams: ['Panthers'], minPrice: 200000 }); const command = flatCommands.createUser({ email: 'user@example.com', emailVerified: true, createdAt: new Date().toISOString(), status: UserStatus.Active, roles: [RoleDefinition.Member], userType: AccountType.Human }); -
Add response type inference:
// Map queries to their response types export interface QueryResponseMap { PlayerSearch: PlayerSearchResult[]; CurrentUser: CurrentUserResponse; PlayerComparison: PlayerComparisonStatsQueryResultContract[]; BallrContests: BallrContestContract[]; GetBallrContestById: BallrContestDetailContract; GetCompetitionBySport: CompetitionContract[]; // ... auto-generated for all queries } // Map commands to their aggregate ID types export interface CommandResponseMap { CreateUser: string; // UserId UpdateUser: string; // UserId DeleteUser: string; // UserId UpdateParticipantSlugName: string; // ParticipantId CreateTeam: string; // TeamId CreateSportingEvent: string; // SportingEventId CreateCompetition: string; // CompetitionId // ... auto-generated for all commands } // Type-safe query execution with inference export async function executeQuery<K extends keyof QueryResponseMap>( client: FoundryQueryClient, query: Extract<Query, { queryType: K }> ): Promise<QueryResponseMap[K]> { const response = await client.query(query); return response.data as QueryResponseMap[K]; } // Type-safe command execution with inference export async function executeCommand<K extends keyof CommandResponseMap>( client: FoundryCommandClient, command: Extract<Command, { commandType: K }> ): Promise<CommandResponse<CommandResponseMap[K]>> { return await client.execute(command); } // Usage with full type inference const playerResults = await executeQuery(client, queries.ballr.superCoach.playerSearch({ minPrice: 200000 })); // Type: PlayerSearchResult[] const userId = await executeCommand(client, commands.core.user.create({ email: 'user@example.com', // ... })); // Type: CommandResponse<string> -
Generate TypeScript enums for constants:
// From C# static classes export const RoleDefinition = { Admin: 'Admin', UserManager: 'UserManager', Member: 'Member', ServiceAccount: 'ServiceAccount' } as const; export type RoleDefinition = typeof RoleDefinition[keyof typeof RoleDefinition]; export const UserStatus = { Active: 'Active', Inactive: 'Inactive', Suspended: 'Suspended', Deleted: 'Deleted' } as const; export type UserStatus = typeof UserStatus[keyof typeof UserStatus]; export const AccountType = { Human: 'Human', ServiceAccount: 'ServiceAccount' } as const; export type AccountType = typeof AccountType[keyof typeof AccountType]; -
Generate authorization metadata:
// Extract from [RequiresRoles] attributes export const QueryAuthorization = { PlayerSearch: { type: 'public' as const, roles: [] }, CurrentUser: { type: 'secure' as const, roles: [RoleDefinition.Member] }, PlayerRoundSearch: { type: 'secure' as const, roles: [RoleDefinition.Member] }, // ... auto-generated for all queries } as const; export const CommandAuthorization = { CreateUser: { type: 'secure' as const, roles: [RoleDefinition.UserManager, RoleDefinition.Admin] }, UpdateParticipantSlugName: { type: 'secure' as const, roles: [RoleDefinition.Admin] }, DeleteUser: { type: 'secure' as const, roles: [RoleDefinition.Admin] }, // ... auto-generated for all commands } as const; // Helper to check if user can execute command export function canExecuteCommand( commandType: keyof typeof CommandAuthorization, userRoles: string[] ): boolean { const authInfo = CommandAuthorization[commandType]; return authInfo.roles.length === 0 || authInfo.roles.some(role => userRoles.includes(role)); } // Usage in React const userRoles = useCurrentUser().roles; const canCreateUser = canExecuteCommand('CreateUser', userRoles); <button disabled={!canCreateUser}> Create User </button>
Phase 4: Build Automation (Week 3)¶
Goal: Integrate with existing CI/CD pipeline
1. Local Development Script:
# tools/generate-typescript-sdk.ps1
param(
[string]$OutputPath = "./sdk/typescript",
[switch]$Watch = $false
)
# Build the generator tool
dotnet build src/Tools/LBS.Tools.TypeScriptGenerator/LBS.Tools.TypeScriptGenerator.csproj
# Run type generation
dotnet run --project src/Tools/LBS.Tools.TypeScriptGenerator/LBS.Tools.TypeScriptGenerator.csproj `
--output $OutputPath `
--assemblies "src/Domain/*/bin/Debug/net10.0/*.dll" `
--include-xml-docs
# Build npm package
cd $OutputPath
npm install
npm run build
npm pack
Write-Host "TypeScript SDK generated at $OutputPath"
2. GitHub Actions Workflow (Private GitHub Packages):
# .github/workflows/publish-typescript-sdk.yml
name: Generate and Publish TypeScript SDK
on:
push:
branches: [ "main" ]
paths:
- 'src/Domain/**/*.cs'
- 'src/Core/**/*.cs'
- 'tools/generate-typescript-sdk.ps1'
workflow_dispatch:
jobs:
generate-and-publish:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write # Required for GitHub Packages
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://npm.pkg.github.com'
scope: '@your-github-org' # Replace with your GitHub org/username
- name: Generate Version
id: version
run: |
VERSION="1.$(date +'%Y.%m%d.%H%M').${{ github.run_number }}"
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Build Generator Tool
run: dotnet build src/Tools/LBS.Tools.TypeScriptGenerator/LBS.Tools.TypeScriptGenerator.csproj
- name: Generate TypeScript Types
run: |
dotnet run --project src/Tools/LBS.Tools.TypeScriptGenerator/LBS.Tools.TypeScriptGenerator.csproj \
--output ./sdk/typescript \
--version ${{ env.VERSION }} \
--include-xml-docs
- name: Build npm Package
working-directory: ./sdk/typescript
run: |
npm install
npm run build
npm run test
- name: Publish to GitHub Packages
working-directory: ./sdk/typescript
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Automatic GitHub token
- name: Create GitHub Release
uses: actions/create-release@v1
with:
tag_name: typescript-sdk-v${{ env.VERSION }}
release_name: TypeScript SDK v${{ env.VERSION }}
body: |
Auto-generated TypeScript SDK for LBS Foundry API
Version: ${{ env.VERSION }}
## Installation
Add to your `.npmrc`:
```
@your-github-org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
```
Install the package:
```bash
npm install @your-github-org/lbs-foundry-typescript-sdk@${{ env.VERSION }}
```
## Changes
- Auto-generated from C# types in commit ${{ github.sha }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3. Package.json Template (GitHub Packages):
{
"name": "@your-github-org/lbs-foundry-typescript-sdk",
"version": "{{VERSION}}",
"description": "Auto-generated TypeScript SDK for LBS Foundry API (Private)",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/your-github-org/LBS.Foundry.git"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "tsc",
"test": "jest",
"lint": "eslint src/**/*.ts",
"prepublishOnly": "npm run build && npm test"
},
"keywords": ["lbs", "foundry", "sports", "api", "sdk"],
"author": "LuckBox Studios",
"license": "UNLICENSED",
"private": false,
"dependencies": {
"axios": "^1.6.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.0",
"jest": "^29.7.0",
"@typescript-eslint/eslint-plugin": "^6.13.0",
"@typescript-eslint/parser": "^6.13.0"
},
"files": [
"dist",
"README.md",
"LICENSE"
]
}
Important Configuration Notes:
- name: Must start with @your-github-org/ to match your GitHub organization
- publishConfig.registry: Points to GitHub Packages
- license: Use "UNLICENSED" for private packages
- repository: Links to your GitHub repo (required for GitHub Packages)
Phase 5: Documentation & Examples (Week 3)¶
Goal: Enable rapid adoption
Tasks: 1. Generate README with usage examples 2. Create example projects: - React app with React Query integration - Node.js service consuming queries and commands - Testing examples with type-safe mocks 3. Add inline JSDoc examples for each query/command 4. Create migration guide from manual types to SDK
Real-World Example - User Management Component:
import { FoundryClient, queries, commands, RoleDefinition, UserStatus, AccountType } from '@lbs-foundry/typescript-sdk';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
const client = new FoundryClient({
baseUrl: process.env.REACT_APP_API_URL,
auth: { type: 'bearer', token: authToken }
});
function UserManagementPage() {
const queryClient = useQueryClient();
// Query current user with full type safety
const { data: currentUser } = useQuery({
queryKey: ['current-user'],
queryFn: () => client.query(queries.currentUser())
});
// Create user mutation with type-safe command
const createUserMutation = useMutation({
mutationFn: (userData: {
email: string;
firstName: string;
lastName: string;
}) => client.execute(
commands.createUser({
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
emailVerified: false,
createdAt: new Date().toISOString(),
status: UserStatus.Active,
roles: [RoleDefinition.Member],
userType: AccountType.Human
})
),
onSuccess: (response) => {
console.log('Created user with ID:', response.aggregateRootId);
queryClient.invalidateQueries(['users']);
}
});
// Check authorization before showing create button
const canCreateUsers = currentUser?.roles.some(
role => [RoleDefinition.Admin, RoleDefinition.UserManager].includes(role)
);
return (
<div>
<h1>User Management</h1>
{canCreateUsers && (
<button onClick={() => createUserMutation.mutate({
email: 'newuser@example.com',
firstName: 'John',
lastName: 'Doe'
})}>
Create User
</button>
)}
{/* TypeScript knows the exact shape of currentUser */}
<div>
Logged in as: {currentUser?.name} ({currentUser?.email})
Roles: {currentUser?.roles.join(', ')}
</div>
</div>
);
}
Example Documentation:
# @your-github-org/lbs-foundry-typescript-sdk
Auto-generated TypeScript SDK for LBS Foundry API with full type safety (Private Package).
## Installation
### Prerequisites
You need a GitHub Personal Access Token (PAT) with `read:packages` permission.
#### Create GitHub Token
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
2. Click "Generate new token (classic)"
3. Give it a name: "LBS Foundry SDK"
4. Select scopes:
- `read:packages` (required to download packages)
- `write:packages` (only if you're publishing)
5. Click "Generate token"
6. **Copy the token immediately** (you won't see it again!)
### Configure npm for GitHub Packages
Create or edit `.npmrc` in your project root (or globally in `~/.npmrc`):
```bash
# .npmrc
@your-github-org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN_HERE
Security: Add .npmrc to .gitignore to avoid committing your token!
Alternative: Use Environment Variable (Recommended for CI/CD):
# .npmrc (safe to commit)
@your-github-org:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
Then set the environment variable:
# Local development
export GITHUB_TOKEN=your_token_here
# Or in CI/CD (GitHub Actions)
# Uses ${{ secrets.GITHUB_TOKEN }} automatically
Install the Package¶
Verify Installation¶
import { FoundryClient, queries, commands } from '@your-github-org/lbs-foundry-typescript-sdk';
console.log('SDK installed successfully!');
Quick Start¶
import { FoundryClient, queries, commands } from '@your-github-org/lbs-foundry-typescript-sdk';
// Initialize client
const client = new FoundryClient({
baseUrl: 'https://api.ballr.live',
auth: {
type: 'bearer',
token: 'your-jwt-token'
}
});
// Execute type-safe query with namespaced access
const playerQuery = queries.superCoach.playerSearch({
teams: ['Panthers', 'Eels'],
minPrice: 200000,
positions: ['Halfback']
});
const response = await client.query(playerQuery);
console.log(response.data); // Fully typed as PlayerSearchResult[]
// Execute type-safe command
const result = await client.execute(
commands.sport.createTeam({
displayName: 'Panthers',
// ... other properties
})
);
console.log('Created team:', result.aggregateRootId);
Using in GitHub Actions (CI/CD)¶
GitHub Actions automatically provides a GITHUB_TOKEN that can access your organization's packages:
# .github/workflows/build-frontend.yml
name: Build Frontend
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://npm.pkg.github.com'
scope: '@your-github-org'
- name: Install dependencies
run: npm install
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Automatic token
- name: Build
run: npm run build
- name: Test
run: npm test
The GITHUB_TOKEN is automatically provided and has read access to packages in the same organization.
React Query Integration¶
import { useQuery, useMutation } from '@tanstack/react-query';
import { queries, commands } from '@lbs-foundry/typescript-sdk';
function PlayerList() {
// Type-safe query
const { data, isLoading } = useQuery({
queryKey: ['players', 'search'],
queryFn: () => foundryClient.query(
queries.playerSearch({ minPrice: 200000 })
)
});
// Type-safe command mutation
const updateParticipantMutation = useMutation({
mutationFn: (params: { participantId: string; slugName: string }) =>
foundryClient.execute(
commands.updateParticipantSlugName({
aggregateRootId: params.participantId,
slugName: params.slugName
})
)
});
if (isLoading) return <Loading />;
return (
<ul>
{data?.data.map(player => (
<li key={player.id}>
{player.displayName}
<button onClick={() => updateParticipantMutation.mutate({
participantId: player.id,
slugName: player.displayName.toLowerCase().replace(/\s+/g, '-')
})}>
Update Slug
</button>
</li>
))}
</ul>
);
}
Available Queries and Commands¶
The SDK auto-generates all queries and commands from your C# codebase:
Queries (via queries object):
- playerSearch - Search for players with filters
- currentUser - Get authenticated user info
- playerComparison - Compare multiple players
- ballrContests - List contests
- ... all other queries in your domain
Commands (via commands object):
- createUser - Create a new user (requires UserManager or Admin)
- updateUser - Update user details (requires UserManager or Admin)
- deleteUser - Delete a user (requires Admin)
- updateParticipantSlugName - Update participant slug (requires Admin)
- createTeam - Create a new team
- ... all other commands in your domain
## Technology Choices
### Namespace Source: **DataContract Attribute Properties**
**Key Decision**: Use `[DataContract(Name, Namespace)]` attributes as the source of truth for TypeScript namespace generation.
**Considered Alternatives**:
| Approach | Pros | Cons | Decision |
|----------|------|------|----------|
| **DataContract Namespace** | Explicit, decoupled from C# structure, part of serialization contract | Requires adding namespace to attributes | **Selected** |
| **Parse C# Namespaces** | Automatic, no attribute changes needed | Fragile, couples client API to internal structure, hard to reorganize | Rejected |
| **Separate Config File** | Very flexible | Disconnected from code, easy to get out of sync | Rejected |
| **Naming Conventions** | Simple to implement | Inflexible, error-prone | Rejected |
**Rationale for DataContract Namespaces**:
1. **Explicit API Design**: Backend developers consciously design client-side structure
2. **Decoupled Evolution**: C# refactoring doesn't break client SDK
3. **Single Source of Truth**: Same attribute used for serialization and SDK generation
4. **Type-Safe**: Namespace constants in `LbsNamespace.cs` prevent typos
5. **Discoverable**: All namespace options visible in one place
### Source Generation Approach: **Custom Roslyn-based Generator**
**Considered Alternatives**:
| Approach | Pros | Cons | Decision |
|----------|------|------|----------|
| **NSwag** | Industry standard, OpenAPI integration | Requires OpenAPI spec, doesn't preserve `DataContract` semantics | Rejected |
| **TypeGen** | Established library, attribute-based | Limited customization, no query/command awareness, can't extract DataContract namespace | Rejected |
| **Custom Roslyn Generator** | Full control, preserves domain semantics, can extract attributes, can inject helpers | Development overhead | **Selected** |
| **T4 Templates** | Built into Visual Studio | Outdated, poor tooling, limited attribute access | Rejected |
**Rationale for Custom Generator**:
1. **Domain Awareness**: Understands `IQuery`, `ICommand`, `IPublicQuery`, `ISecureQuery` semantics
2. **Attribute Extraction**: Can read `DataContract` Name and Namespace properties via Roslyn
3. **Exact Mapping**: Preserves `DataContract` names and query type resolution logic
4. **Enhanced Output**: Generates helper functions, builders, and type mappings beyond basic types
5. **Future Extensibility**: Can add WebSocket support, caching metadata, validation rules
6. **Zero Runtime Dependencies**: Pure compile-time generation
### Package Distribution: **GitHub Packages (Private)**
**Registry**: GitHub Packages (private, requires authentication)
**Access**: Private - requires GitHub token for installation
**CI/CD**: GitHub Actions automatic publishing on C# type changes
## Package Architecture Options
### Option 1: Monolithic SDK (Simpler, Recommended for MVP)
**Single package with all modules**:
- **Package**: `@your-github-org/foundry-typescript-sdk`
- **Contains**: Core, Sport, SuperCoach, Ballr, Discord - everything
- **Version**: Single version for entire SDK
**Pros**:
- Simple to maintain - one codebase, one version
- Easy to consume - `npm install` one package
- No dependency coordination needed
- Faster to implement and ship
**Cons**:
- Larger bundle size - includes unused modules
- Cannot update modules independently
- Breaking changes in one module affect all consumers
- Couples unrelated features (Ballr + Discord)
**Usage**:
```typescript
import { FoundryClient, queries, commands } from '@your-github-org/foundry-typescript-sdk';
// All modules available
queries.core.currentUser()
queries.sport.getCompetitionBySport({ ... })
queries.superCoach.playerSearch({ ... })
queries.ballr.listContests({ ... })
Option 2: Modular Packages (Better Long-term, Recommended for Scale)¶
Separate packages per module aligned with ModuleDefinition:
@your-github-org/foundry-sdk-core # Core types + FoundryClient
@your-github-org/foundry-sdk-sport # Sport module types
@your-github-org/foundry-sdk-supercoach # SuperCoach module types
@your-github-org/foundry-sdk-ballr # Ballr module types
@your-github-org/foundry-sdk-discord # Discord module types
@your-github-org/foundry-sdk # Meta-package (installs all)
Package Structure:
Core Package (required, contains client):
// @your-github-org/foundry-sdk-core/package.json
{
"name": "@your-github-org/foundry-sdk-core",
"version": "1.2025.0123.1030.42",
"dependencies": {
"axios": "^1.6.0"
}
}
Contains:
- FoundryClient class (query/command executors)
- Core module queries/commands (queries.core.*, commands.core.*)
- Shared types (RoleDefinition, UserStatus, etc.)
- Base infrastructure
Feature Packages (optional, depend on core):
// @your-github-org/foundry-sdk-sport/package.json
{
"name": "@your-github-org/foundry-sdk-sport",
"version": "1.2025.0123.1030.42",
"peerDependencies": {
"@your-github-org/foundry-sdk-core": "^1.0.0"
}
}
Contains:
- Sport module queries/commands (queries.sport.*, commands.sport.*)
- Sport-specific types (TeamContract, CompetitionContract, etc.)
// @your-github-org/foundry-sdk-ballr/package.json
{
"name": "@your-github-org/foundry-sdk-ballr",
"version": "2.2025.0125.1400.15", // ← Different version!
"peerDependencies": {
"@your-github-org/foundry-sdk-core": "^1.0.0"
}
}
Meta Package (convenience, installs all):
// @your-github-org/foundry-sdk/package.json
{
"name": "@your-github-org/foundry-sdk",
"version": "1.2025.0123.1030.42",
"dependencies": {
"@your-github-org/foundry-sdk-core": "^1.0.0",
"@your-github-org/foundry-sdk-sport": "^1.0.0",
"@your-github-org/foundry-sdk-supercoach": "^1.0.0",
"@your-github-org/foundry-sdk-ballr": "^2.0.0",
"@your-github-org/foundry-sdk-discord": "^1.0.0"
}
}
Pros: - Smaller bundles - Only install what you need - Independent versioning - Ballr can be v2, Sport can be v1 - Tree-shakeable - Better for web apps (bundle size matters) - Team autonomy - Ballr team updates their package independently - Breaking changes isolated - Sport breaking change doesn't affect Ballr consumers - Clearer dependencies - Know exactly what you're using
Cons: - More complex to maintain - multiple packages, versions - Dependency coordination - ensure compatible peer dependencies - More CI/CD jobs - publish multiple packages - Initial setup overhead - more configuration
Usage - Install Only What You Need:
# Ballr frontend - only needs Ballr module
pnpm add @your-github-org/foundry-sdk-core
pnpm add @your-github-org/foundry-sdk-ballr
# Sport admin dashboard - only needs Sport module
pnpm add @your-github-org/foundry-sdk-core
pnpm add @your-github-org/foundry-sdk-sport
# Full app - needs everything
pnpm add @your-github-org/foundry-sdk # Installs all modules
Usage - Import Only What You Need:
// Ballr app - only imports Ballr types
import { FoundryClient } from '@your-github-org/foundry-sdk-core';
import { queries as ballrQueries, commands as ballrCommands } from '@your-github-org/foundry-sdk-ballr';
const client = new FoundryClient({ ... });
const contests = await client.query(ballrQueries.listContests({ ... }));
// No Sport/Discord types in bundle!
Generated File Structure (Modular):
packages/
├── core/ # @your-github-org/foundry-sdk-core
│ ├── src/
│ │ ├── client/
│ │ │ ├── FoundryClient.ts # Main client
│ │ │ ├── FoundryQueryClient.ts
│ │ │ └── FoundryCommandClient.ts
│ │ ├── generated/
│ │ │ ├── types/
│ │ │ │ ├── queries.ts # Core queries only
│ │ │ │ ├── commands.ts # Core commands only
│ │ │ │ └── responses.ts
│ │ │ ├── queries.ts # queries.core.*
│ │ │ ├── commands.ts # commands.core.*
│ │ │ └── constants.ts # Shared constants
│ │ └── index.ts
│ └── package.json
│
├── sport/ # @your-github-org/foundry-sdk-sport
│ ├── src/
│ │ ├── generated/
│ │ │ ├── types/
│ │ │ │ ├── queries.ts # Sport queries
│ │ │ │ ├── commands.ts # Sport commands
│ │ │ │ └── responses.ts
│ │ │ ├── queries.ts # queries.sport.*
│ │ │ └── commands.ts # commands.sport.*
│ │ └── index.ts
│ └── package.json
│
├── ballr/ # @your-github-org/foundry-sdk-ballr
│ ├── src/
│ │ ├── generated/
│ │ │ ├── types/
│ │ │ │ ├── queries.ts # Ballr queries
│ │ │ │ ├── commands.ts # Ballr commands
│ │ │ │ └── responses.ts
│ │ │ ├── queries.ts # queries.ballr.*
│ │ │ └── commands.ts # commands.ballr.*
│ │ └── index.ts
│ └── package.json
│
├── supercoach/ # @your-github-org/foundry-sdk-supercoach
│ └── ...
│
├── discord/ # @your-github-org/foundry-sdk-discord
│ └── ...
│
└── foundry-sdk/ # @your-github-org/foundry-sdk (meta)
├── src/
│ └── index.ts # Re-exports all packages
└── package.json # Depends on all modules
Option 3: Hybrid Approach (Recommended Starting Point)¶
Start monolithic, migrate to modular as needed:
Phase 1: Ship monolithic SDK - Single package with all modules - Get to production quickly - Learn usage patterns
Phase 2: Extract high-change modules - Keep core monolithic - Split out Ballr if it changes frequently - Split out experimental modules
Phase 3: Full modular if needed - Migrate to fully modular only if bundle size or versioning becomes a pain
Recommended Approach: Start Simple - Single Package¶
Key Insight¶
The client is the same, only types differ!
FoundryClient→ Same for everyoneclient.query()→ Same APIclient.execute()→ Same API- Types (queries, commands, responses) → Only difference
Recommendation: Single Package with Tree-Shaking¶
Package: @your-github-org/foundry-typescript-sdk
Structure:
// Single package, organized by module
import { FoundryClient } from '@your-github-org/foundry-typescript-sdk';
// Import only the types you need - modern bundlers tree-shake the rest
import { queries } from '@your-github-org/foundry-typescript-sdk/ballr';
import { queries as sportQueries } from '@your-github-org/foundry-typescript-sdk/sport';
// Or import everything (simpler, slightly larger bundle)
import { queries, commands } from '@your-github-org/foundry-typescript-sdk';
Package Export Structure (package.json):
{
"name": "@your-github-org/foundry-typescript-sdk",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./core": {
"types": "./dist/modules/core/index.d.ts",
"import": "./dist/modules/core/index.js"
},
"./ballr": {
"types": "./dist/modules/ballr/index.d.ts",
"import": "./dist/modules/ballr/index.js"
},
"./sport": {
"types": "./dist/modules/sport/index.d.ts",
"import": "./dist/modules/sport/index.js"
},
"./supercoach": {
"types": "./dist/modules/supercoach/index.d.ts",
"import": "./dist/modules/supercoach/index.js"
}
}
}
File Structure:
sdk/typescript/
├── src/
│ ├── client/
│ │ ├── FoundryClient.ts # Single client for everyone
│ │ ├── FoundryQueryClient.ts
│ │ └── FoundryCommandClient.ts
│ ├── modules/
│ │ ├── core/
│ │ │ ├── index.ts # Export: queries.core.*, commands.core.*
│ │ │ └── types/ # Core types only
│ │ ├── ballr/
│ │ │ ├── index.ts # Export: queries.ballr.*, commands.ballr.*
│ │ │ └── types/ # Ballr types only
│ │ ├── sport/
│ │ │ ├── index.ts # Export: queries.sport.*, commands.sport.*
│ │ │ └── types/ # Sport types only
│ │ └── supercoach/
│ │ ├── index.ts # Export: queries.supercoach.*
│ │ └── types/ # SuperCoach types only
│ └── index.ts # Main export - all modules
└── package.json
Usage - Import Only What You Need:
// Ballr app - only import Ballr types (tree-shaken automatically!)
import { FoundryClient } from '@your-github-org/foundry-typescript-sdk';
import { queries, commands } from '@your-github-org/foundry-typescript-sdk/ballr';
const client = new FoundryClient({ ... });
const contests = await client.query(queries.listContests({ ... }));
// Webpack/Vite will remove Sport/SuperCoach types from bundle
Usage - Import Everything (Simpler):
// Full app - import all types
import { FoundryClient, queries, commands } from '@your-github-org/foundry-typescript-sdk';
const client = new FoundryClient({ ... });
await client.query(queries.ballr.listContests({ ... }));
await client.query(queries.sport.getCompetitionBySport({ ... }));
Why This is Better¶
Pros:
- One package - Simple to maintain and version
- One client - No duplication, no confusion
- Tree-shakeable - Bundlers remove unused types automatically
- Flexible imports - Import per-module or everything
- Same API - client.query() / client.execute() for everyone
- Fast to ship - No multi-package coordination
When to Split Packages (only if these happen): - Ballr team wants to publish their SDK publicly to npm (separate product) - Version Ballr and Sport independently (major v2 for one, v1 for other) - Tree-shaking isn't working (bundle still too large despite subpath imports)
Bottom Line¶
Don't overcomplicate it. One package, organized by module, with subpath imports. Modern bundlers handle the rest.
// That's it - simple!
import { FoundryClient } from '@your-github-org/foundry-typescript-sdk';
import { queries } from '@your-github-org/foundry-typescript-sdk/ballr';
Python SDK Support (Phase 4)¶
Following the successful implementation of the TypeScript SDK, the generator was extended to support multiple languages with Python as the second target language.
Architecture Changes¶
The generator was refactored to support language abstraction:
Before (TypeScript-only):
LBS.Tools.TypeScriptGenerator/
├── TypeScanner.cs # Hardcoded TypeScript mapping
├── TypeMapper.cs
├── TypeScriptGenerator.cs
└── Program.cs
After (Multi-language):
LBS.Tools.SdkGenerator/
├── ICodeGenerator.cs # Language abstraction interface
├── TypeScanner.cs # Language-agnostic TypeInfo
├── TypeMapper.cs # TypeScript implementation
├── TypeScriptGenerator.cs # Implements ICodeGenerator
├── PythonTypeMapper.cs # Python type mapping
├── PythonGenerator.cs # Implements ICodeGenerator
└── Program.cs # --language parameter
Language Abstraction Interface¶
public interface ICodeGenerator
{
GeneratedOutput Generate(List<ScannedType> types);
string FileExtension { get; } // ".ts" or ".py"
string LanguageName { get; } // "TypeScript" or "Python"
}
Python Type Mapping¶
| C# Type | Python Type |
|---|---|
string |
str |
int, long |
int |
decimal, double |
float |
bool |
bool |
DateTime |
datetime |
Guid |
str |
List<T> |
list[T] |
Dictionary<K,V> |
dict[K, V] |
T? (nullable) |
T \| None |
Python Code Generation¶
Generated Dataclass:
from dataclasses import dataclass
@dataclass
class PlayerSearch:
"""Auto-generated dataclass."""
query_type: str = 'PlayerSearch'
teams: list[str] | None
search_term: str | None
min_price: int
max_price: int
positions: list[str] | None
players_to_include_in_results: list[str]
Generated Builder Function:
def player_search(
teams: list[str] | None,
search_term: str | None,
min_price: int,
max_price: int,
positions: list[str] | None,
players_to_include_in_results: list[str]
) -> PlayerSearch:
"""Create a PlayerSearch instance."""
return PlayerSearch(
query_type='PlayerSearch',
teams=teams,
search_term=search_term,
min_price=min_price,
max_price=max_price,
positions=positions,
players_to_include_in_results=players_to_include_in_results,
)
Python Client Library¶
Features:
- Async/await using httpx
- Retry logic with exponential backoff
- Generic type hints for IDE support
- Full type safety with Python 3.11+
- Modern pyproject.toml packaging
Example Usage:
import asyncio
from foundry_sdk import FoundryClient, FoundryClientConfig
from foundry_sdk.generated.modules.super_coach.queries import player_search
async def main():
client = FoundryClient(FoundryClientConfig(
base_url="https://api.foundry.com",
auth_token="your-bearer-token"
))
result = await client.query(player_search(
search_term="Smith",
min_price=100000,
max_price=500000,
teams=None,
positions=None,
players_to_include_in_results=[]
))
if result.success:
print(f"Found {len(result.data)} players")
asyncio.run(main())
Python Package Structure¶
sdk/python/
├── pyproject.toml # Modern Python packaging
├── README.md # Comprehensive docs
└── src/foundry_sdk/
├── __init__.py # Main exports
├── types.py # Core types
├── client.py # FoundryClient
├── query_client.py # Query execution
├── command_client.py # Command execution
├── py.typed # PEP 561 marker
└── generated/ # Auto-generated
├── types.py
├── __init__.py
└── modules/
├── core/
├── sport/
├── super_coach/ # snake_case naming
├── ballr/
└── discord/
CLI Usage¶
# Generate TypeScript SDK
dotnet run --project src/Tools/LBS.Tools.SdkGenerator -- \
<assembly-dir> <reference-dir> <output-dir> --language typescript
# Generate Python SDK
dotnet run --project src/Tools/LBS.Tools.SdkGenerator -- \
<assembly-dir> <reference-dir> <output-dir> --language python
Convenience Scripts:
# Windows
.\scripts\generate-typescript-sdk.ps1
.\scripts\generate-python-sdk.ps1
# Linux/macOS
./scripts/generate-typescript-sdk.sh
./scripts/generate-python-sdk.sh
Benefits of Multi-Language Support¶
- Consistent API - Same builder patterns across TypeScript and Python
- Shared Infrastructure - Single codebase for type scanning
- Easy Extension - Add new languages by implementing
ICodeGenerator - Maintained in Sync - Both SDKs generated from same source of truth
- Use Case Coverage - TypeScript for web, Python for scripts/data science
Adding New Languages¶
The architecture makes it straightforward to add support for other languages:
- Create
{Language}TypeMapper.cs- Map C# types to target language - Create
{Language}Generator.cs- ImplementICodeGenerator - Register in
Program.csswitch statement - Create generation script
Example languages that could be easily added: - C# Client SDK - For external .NET applications - Go - For backend microservices - Rust - For high-performance clients - Java/Kotlin - For Android applications
Import API Support (Phase 5)¶
The SDK now includes support for the Import API (POST /api/import), enabling external systems to execute bulk data imports through type-safe importers.
What are Importers?¶
Importers are specialized handlers that convert external data into domain commands for transactional execution. They follow the pattern:
public interface IImporter<TInput>
{
Task<IEnumerable<IDomainCommand>> ExecuteAsync(TInput input);
}
Example C# Importer:
public sealed class ModelOutputImporter : IImporter<ModelOutputImporterContract>
{
public async Task<IEnumerable<IDomainCommand>> ExecuteAsync(ModelOutputImporterContract input)
{
var commands = new List<IDomainCommand>();
foreach (var projection in input.PlayerProjections)
{
var command = new SetModelProjectionCommand
{
AggregateRootId = aggregateId,
ParticipantId = participantId,
Round = input.Round,
Points = projection.ExpectedPoints,
// ...
};
commands.Add(command);
}
return commands;
}
}
Available Importers¶
| Importer | Module | Purpose | Use Case |
|---|---|---|---|
| ClerkWebhookImporter | Core | User provisioning webhooks | Clerk authentication integration |
| ModelOutputImporter | SuperCoach | ML model predictions | Data science teams pushing player projections |
| SuperCoachImporter | SuperCoach | SuperCoach data sync | External systems importing fantasy stats |
| SuperCoachLeaderBoardImporter | SuperCoach | Contest leaderboards | Fantasy league integrations |
| FoxSportsImporter | Sport | Fox Sports feeds | Data integration from Fox Sports API |
SDK Integration¶
The SDK generates import builders for each importer contract, providing type-safe construction with IntelliSense support.
TypeScript Example:
import { FoundryClient } from '@luckboxstudios/foundry-sdk';
import { imports } from '@luckboxstudios/foundry-sdk/supercoach';
const client = new FoundryClient({
baseUrl: 'https://api.foundry.com',
authToken: 'your-bearer-token'
});
// Use generated import builder for type safety
const result = await client.import(
'ModelOutput', // Importer type (from ImporterTypeRegistry)
imports.modelOutput({
seasonYear: 2025,
round: 15,
playerProjections: [
{
foxFeedId: '12345',
expectedPoints: 45.2,
expectedMinutes: 65.5,
expectedPrice: 350000
}
]
})
);
console.log(`Executed ${result.commandsExecuted} commands`);
console.log(`Generated ${result.eventsGenerated} events`);
console.log(`Processed ${result.aggregatesProcessed} aggregates`);
Python Example:
from foundry_sdk import FoundryClient, FoundryClientConfig
from foundry_sdk.supercoach import imports
client = FoundryClient(FoundryClientConfig(
base_url='https://api.foundry.com',
auth_token='your-bearer-token'
))
# Use generated import builder for type safety
result = await client.import_data(
'ModelOutput', # Importer type (from ImporterTypeRegistry)
imports.model_output_import(
season_year=2025,
round=15,
player_projections=[
{
'fox_feed_id': '12345',
'expected_points': 45.2,
'expected_minutes': 65.5,
'expected_price': 350000
}
]
)
)
print(f"Success: {result.success}")
print(f"Commands executed: {result.commands_executed}")
print(f"Events generated: {result.events_generated}")
Import API Response¶
interface ImportExecutionResult {
requestId: string;
importId: string;
success: boolean;
importerType: string;
commandsExecuted: number;
eventsGenerated: number;
aggregatesProcessed: number;
executionDuration: string;
errorMessage?: string;
errorCode?: string;
}
Transactional Behavior¶
Importers automatically batch commands by aggregate root and execute in separate transactions:
// These projections will be processed in separate transactions per player
const result = await client.import({
importerType: 'ModelOutput',
importData: {
playerProjections: [
{ foxFeedId: '001', ... }, // Transaction 1 (Player 001)
{ foxFeedId: '001', ... }, // Transaction 1 (Same player)
{ foxFeedId: '002', ... }, // Transaction 2 (Player 002)
{ foxFeedId: '003', ... } // Transaction 3 (Player 003)
]
}
});
Failure Handling: - Each aggregate processes in its own transaction - Failed aggregates rollback independently - Successful aggregates remain committed - Detailed error reporting per aggregate
Benefits for External Systems¶
Data Science Teams:
- Push ML predictions via ModelOutputImporter
- Type-safe contracts for prediction schemas
- Automated retry logic with exponential backoff
Integration Pipelines: - Sync external data sources (Fox Sports, NRL) - Bulk operations with transactional guarantees - Progress tracking via import execution IDs
Administrative Operations: - Bulk metadata updates (slug names, URLs) - Seeding/migration operations - Testing data provisioning
Authentication & Authorization¶
Import API requires same authentication as commands:
- Human Users: Clerk JWT Bearer tokens
- Service Accounts: Basic Auth (email/password)
- Role-Based Access: Importers respect [RequiresRoles] attributes
// Service account authentication
const client = new FoundryClient({
baseUrl: 'https://api.foundry.com',
authType: 'basic',
email: 'service@example.com',
password: 'secure-password'
});
// Execute privileged import
await client.import({
importerType: 'ModelOutput', // Requires ServiceAccount role
importData: { ... }
});
Generated Import Builders¶
The SDK automatically generates import builders for each importer contract, organized by module:
TypeScript Structure:
// Generated: sdk/typescript/generated/supercoach/imports.ts
export const imports = {
// Function name derived from type name (removes "Import" suffix)
modelOutput: (data: ModelOutputImport): ModelOutputImport => data,
superCoach: (data: SuperCoachImport): SuperCoachImport => data,
superCoachLeaderBoard: (data: SuperCoachLeaderBoardImport): SuperCoachLeaderBoardImport => data,
} as const;
Python Structure:
# Generated: sdk/python/generated/supercoach/imports.py
def model_output_import(
season_year: int,
round: int,
player_projections: list
) -> ModelOutputImport:
return ModelOutputImport(
season_year=season_year,
round=round,
player_projections=player_projections
)
Key Characteristics:
- Module Organization: Import builders grouped by namespace (Core, Sport, SuperCoach, Ballr, Discord)
- Naming Convention: Function names derived from type names (e.g., ModelOutputImport → modelOutput() in TypeScript, model_output_import() in Python)
- Type Safety: TypeScript uses identity functions for type checking; Python uses dataclass constructors
- No Discriminator: Unlike queries/commands, imports don't need a queryType field (wrapped by API in { importerType, importData } payload)
Available Importers:
- Core: clerkWebhookImport - Clerk user provisioning webhooks
- SuperCoach: modelOutputImport, superCoachImport, superCoachLeaderBoardImport - Fantasy sports data
- Sport: foxSportsImport - Sports data feeds
- Additional importers registered via ImporterTypeRegistry
Design Decisions¶
Include All Importers in SDK: Authentication handles access control, no need for filtering
Separate Module Organization: Importers exposed via dedicated namespace/module
Type-Safe Contracts: Importer input types generated alongside queries/commands
Unified Client API: Same client handles queries, commands, and imports
Consequences¶
Positive¶
Type Safety: Compile-time validation of all API calls, catch errors before runtime
Developer Productivity: IntelliSense, autocomplete, instant documentation in IDE
Reduced Bugs: Eliminate entire class of runtime errors from API misuse
API Discoverability: Developers can explore available queries/commands via IDE
Automatic Updates: SDK regenerates on every C# change, always in sync
Better Testing: Type-safe mocks, shared contracts between frontend and backend tests
Documentation: Generated types serve as self-documenting API reference
Consistency: Single source of truth (C# types) eliminates drift
Migration Path: Can gradually migrate existing code to use SDK
Private Distribution: GitHub Packages keeps SDK private to your organization
No npm Account Required: Uses GitHub authentication (already have it)
Free for Private Repos: GitHub Packages is free for private repositories
Access Control: Leverages GitHub's existing team/org permissions
Negative¶
Build Complexity: Additional build step and tooling to maintain
Generation Time: Adds ~30-60 seconds to build pipeline
Breaking Changes: C# type changes automatically propagate to frontend (requires coordination)
Package Management: Teams must update npm package when API changes
Learning Curve: Developers need to understand generated SDK structure
Debugging: Generated code can be harder to debug than hand-written code
Custom Code: Difficult to extend generated types with custom behavior
Token Management: Developers need GitHub Personal Access Tokens configured
Initial Setup: New team members must configure .npmrc with authentication
CI/CD Configuration: Each CI/CD environment needs GitHub token setup
No Public Access: Cannot share SDK with external developers/partners without GitHub access
Known Issues¶
Nested Type Generation (Critical): Currently, all complex nested types are mapped to any instead of generating proper TypeScript interfaces.
Current Behavior:
export interface CreateLineup {
queryType: 'CreateLineup';
sportingEventId: string;
participants: any[]; // Should be LineupParticipant[]
dataSource: any; // Should be DataSource
sourceId: string | null;
}
Expected Behavior:
export interface DataSource {
id: string;
name: string;
type: string;
}
export interface LineupParticipant {
participantId: string;
position: string;
jerseyNumber: number;
}
export interface CreateLineup {
queryType: 'CreateLineup';
sportingEventId: string;
participants: LineupParticipant[]; // Properly typed
dataSource: DataSource; // Properly typed
sourceId: string | null;
}
Root Cause:
The TypeMapper currently only maps primitive types and collections. When encountering complex types (classes/structs), it defaults to any:
// TypeMapper.cs (lines 92-101)
var simpleName = typeInfo.FullTypeName.Split('.').Last().Split('`').First();
if (simpleName.EndsWith("Id"))
{
return "string"; // Handle strongly-typed IDs
}
return "any"; // All other complex types become 'any'
Impact: - Loss of Type Safety: Defeats primary purpose of generated SDK - No IntelliSense: IDE cannot provide autocomplete for nested objects - Runtime Errors: Type mismatches only caught at runtime, not compile-time - Poor Developer Experience: Must reference C# code to understand structure - Import API Blocked: Cannot generate importer contracts without nested type support
Status: RESOLVED
This issue has been resolved by implementing proper nested type generation:
- All complex types referenced by queries, commands, and imports are now recursively scanned
- TypeScript interfaces are generated for all nested types with [DataContract] attributes
- Python dataclasses are generated with proper type annotations
- Import API support is now fully functional with type-safe nested contracts
Examples of Affected Types:
- CreateLineup.participants → any[] instead of LineupParticipant[]
- CreateCompetition.dataSource → any instead of DataSource
- ModelOutputImporterContract.playerProjections → any[] instead of NrlSuperCoachModelPlayerOutputContract[]
- All Dictionary<K,V> with complex value types → Record<K, any>
Required Solution:
1. Dependency Scanning: Recursively scan all referenced types in properties
2. Type Generation: Generate TypeScript interfaces for all complex types (not just queries/commands)
3. Circular Reference Handling: Detect and handle type cycles (A → B → A)
4. Type Mapping Update: Use generated type names instead of any
Workaround (Temporary): Developers must manually define nested types:
// Manual type definitions required
interface LineupParticipant {
participantId: string;
position: string;
jerseyNumber: number;
}
// Use assertion to add type safety
const lineup: CreateLineup = {
queryType: 'CreateLineup',
participants: [...] as LineupParticipant[]
};
Priority: HIGH - Blocks Phase 5 (Import API support) and significantly degrades SDK value
Risks and Mitigations¶
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Breaking Changes in Generated Types | High | High | Semantic versioning with major version bumps; deprecation warnings |
| Build Pipeline Failures | Medium | Medium | Comprehensive tests; fallback to previous SDK version; monitoring |
| Type Mapping Errors | Low | High | Extensive test suite comparing C# serialization to TypeScript types |
| GitHub Packages Downtime | Low | Medium | Cache packages in CI/CD; local npm cache; documented recovery process |
| Generator Tool Bugs | Medium | High | Thorough testing; manual review of generated output; versioned tool |
| C# API Changes Not Reflected | Low | High | CI/CD blocks merge if generation fails; automated PR checks |
| Lost GitHub Tokens | Medium | Medium | Document token rotation process; use org-level tokens; store in secrets manager |
| New Developer Onboarding Issues | High | Low | Clear documentation; automated setup script; onboarding checklist |
| Token Permission Issues | Medium | Medium | Use minimal required scopes (read:packages); document exact requirements |
Success Metrics¶
Technical KPIs¶
- Type Coverage: 100% of public queries/commands have TypeScript equivalents
- Build Time: SDK generation completes in < 60 seconds
- Package Size: Generated SDK < 500KB (minified + gzipped)
- Type Accuracy: Zero runtime type mismatches in production
- Update Frequency: SDK published within 5 minutes of C# changes merging
Developer KPIs¶
- Adoption Rate: 90% of API calls use SDK within 3 months
- Bug Reduction: 50% reduction in API-related frontend bugs
- Development Speed: 30% faster feature development using type-safe APIs
- Developer Satisfaction: Positive feedback on IntelliSense and type safety
Implementation Checklist¶
Week 1: Foundation¶
- [ ] Create
LBS.Tools.TypeScriptGeneratorproject - [ ] Implement Roslyn-based assembly scanner
- [ ] Scan for
IQuery,ISecureQuery,IPublicQueryimplementations - [ ] Scan for
DomainCommand<T>implementations - [ ] Extract
[DataContract(Name, Namespace)]attributes from types - [ ] Parse DataContract Namespace to build TypeScript namespace hierarchy
- [ ] Build C# to TypeScript type mapper
- [ ] Generate TypeScript interfaces for queries
- [ ] Generate TypeScript interfaces for commands
- [ ] Extract
[RequiresRoles]attributes for authorization metadata - [ ] Add XML doc to JSDoc conversion
- [ ] Create test suite for type generation
- [ ] Add fallback logic for types without explicit Namespace
Week 2: Client Library & Namespacing¶
- [ ] Expand
LbsNamespace.cswith client-side namespace constants - [ ] Apply namespace attributes to all queries and commands
- [ ] Implement
FoundryClientbase class - [ ] Create
FoundryQueryClientwith query execution methods - [ ] Create
FoundryCommandClientwith command execution methods - [ ] Add authentication support (Bearer + Basic)
- [ ] Implement error handling and retries
- [ ] Generate hierarchical namespace structure from DataContract Namespace (
queries.ballr.superCoach.*) - [ ] Generate namespaced query builder functions
- [ ] Generate namespaced command builder functions
- [ ] Generate flat query builders for backward compatibility
- [ ] Generate flat command builders for backward compatibility
- [ ] Generate query-to-response type mappings
- [ ] Generate command-to-aggregate type mappings
- [ ] Generate authorization metadata objects
- [ ] Create authorization helper functions
- [ ] Create domain-specific export modules for tree-shaking
- [ ] Test namespace generation with various DataContract configurations
Week 3: Automation & Distribution¶
- [ ] Create PowerShell script for local generation
- [ ] Set up GitHub Actions workflow
- [ ] Configure npm publishing with credentials
- [ ] Create package.json template with metadata
- [ ] Add versioning strategy
- [ ] Write comprehensive README
- [ ] Create example projects
- [ ] Document migration from manual types
Week 4: Testing & Refinement¶
- [ ] Test generation with all existing queries/commands
- [ ] Validate type accuracy with integration tests
- [ ] Perform end-to-end testing in React app
- [ ] Gather team feedback
- [ ] Refine developer experience
- [ ] Create troubleshooting guide
- [ ] Plan ongoing maintenance
References¶
Internal Documentation¶
External Resources¶
Review and Approval¶
- Technical Lead: [Name] - Architecture review and feasibility assessment
- Frontend Lead: [Name] - Developer experience and integration review
- Backend Lead: [Name] - Type generation accuracy and maintenance review
- DevOps Lead: [Name] - CI/CD integration and automation review
Date: January 2025 Review Date: February 2025 Status: Awaiting stakeholder review and feasibility validation