-
Notifications
You must be signed in to change notification settings - Fork 0
05 01 DataGateway Pattern
The IDataGateway pattern routes data commands to appropriate connections without requiring repository or service layer wrappers. API endpoints inject IDataGateway directly and execute data commands (Query, Insert, Update, Delete) in-place.
-
Direct Injection - API endpoints inject
IDataGatewaydirectly - No Service Wrappers - Do NOT create domain services that wrap IDataGateway
-
Command-Based - Use concrete data commands (
QueryCommand<T>,InsertCommand<T>, etc.) - DataStore/Path/Container Routing - Commands specify DataStoreName, PathName, and ContainerName
-
Railway-Oriented - All operations return
IGenericResult<T>for consistent error handling
Every data command requires three location parameters:
| Parameter | Description | Example |
|---|---|---|
| DataStoreName | The DataStore (connection/database) |
"ConfigurationDb", "NflStats"
|
| PathName | The DataPath within the DataStore (schema) |
"cfg", "dbo", "auth"
|
| ContainerName | The DataContainer (table/view) |
"Customers", "Users"
|
// Fluent API (preferred)
var command = Query.From<Customer>("ConfigurationDb", "cfg", "Customers")
.Where(c => c.Name).Equal("Acme")
.Build();
// Or for Insert/Update/Delete
var command = Insert.Into<Customer>("Customers")
.DataStore("ConfigurationDb")
.Path("cfg")
.Value(customer);From IDataGateway.cs:9-24:
/// <summary>
/// Service that routes data commands to the appropriate connection.
/// The DataGateway selects the correct DataStore based on the command's DataStoreName
/// and delegates execution to that connection.
/// </summary>
public interface IDataGateway
{
/// <summary>
/// Executes a data command by routing it to the appropriate connection.
/// </summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="command">The data command containing DataStoreName, PathName, and ContainerName.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The execution result.</returns>
Task<IGenericResult<T>> Execute<T>(IDataCommand command, CancellationToken cancellationToken = default);
}DataGateway is registered during application startup. See the framework extension methods for registration patterns.
// Register DataGateway service
// Routes IDataCommand to appropriate connections
// API endpoints inject IDataGateway directly - no service wrappers
services.AddDataGateway();FractalDataWorks provides four core data commands that work across all connection types:
Retrieves data (SELECT operation). Returns IEnumerable<T>.
From QueryCommand.cs:60-106:
[TypeOption(typeof(DataCommands), "Query")]
public sealed class QueryCommand<T> : DataCommandBase<IEnumerable<T>>, IQueryCommand
{
public QueryCommand(string containerName)
: base("Query", containerName)
{
}
/// <summary>
/// Gets or sets the filter expression (WHERE clause).
/// </summary>
public IFilterExpression? Filter { get; init; }
/// <summary>
/// Gets or sets the projection expression (SELECT clause).
/// </summary>
public IProjectionExpression? Projection { get; init; }
/// <summary>
/// Gets or sets the ordering expression (ORDER BY clause).
/// </summary>
public IOrderingExpression? Ordering { get; init; }
/// <summary>
/// Gets or sets the paging expression (SKIP/TAKE).
/// </summary>
public IPagingExpression? Paging { get; init; }
/// <summary>
/// Gets or sets the aggregation expression (GROUP BY).
/// </summary>
public IAggregationExpression? Aggregation { get; init; }
/// <summary>
/// Gets or sets the join expressions (JOIN clauses).
/// </summary>
public IReadOnlyList<IJoinExpression> Joins { get; init; } = [];
}Inserts new records (INSERT operation). Returns the inserted entity or affected row count.
From InsertCommand.cs:35-47:
[TypeOption(typeof(DataCommands), "Insert")]
public sealed class InsertCommand<T> : DataCommandBase<int, T>
{
/// <summary>
/// Initializes a new instance of the <see cref="InsertCommand{T}"/> class.
/// </summary>
/// <param name="containerName">The name of the container to insert into.</param>
/// <param name="data">The entity to insert.</param>
public InsertCommand(string containerName, T data)
: base("Insert", containerName, data)
{
}
}Updates existing records (UPDATE operation). Returns affected row count.
From UpdateCommand.cs:47-65:
[TypeOption(typeof(DataCommands), "Update")]
public sealed class UpdateCommand<T> : DataCommandBase<int, T>, IFilterableCommand
{
/// <summary>
/// Initializes a new instance of the <see cref="UpdateCommand{T}"/> class.
/// </summary>
/// <param name="containerName">The name of the container to update.</param>
/// <param name="data">The updated entity data.</param>
public UpdateCommand(string containerName, T data)
: base("Update", containerName, data)
{
}
/// <summary>
/// Gets or sets the filter expression (WHERE clause for update).
/// Determines which records to update.
/// </summary>
public IFilterExpression? Filter { get; init; }
}Deletes records (DELETE operation). Returns affected row count.
From DeleteCommand.cs:45-62:
[TypeOption(typeof(DataCommands), "Delete")]
public sealed class DeleteCommand : DataCommandBase<int>, IFilterableCommand
{
/// <summary>
/// Initializes a new instance of the <see cref="DeleteCommand"/> class.
/// </summary>
/// <param name="containerName">The name of the container to delete from.</param>
public DeleteCommand(string containerName)
: base("Delete", containerName)
{
}
/// <summary>
/// Gets or sets the filter expression (WHERE clause for delete).
/// Determines which records to delete.
/// </summary>
public IFilterExpression? Filter { get; init; }
}When using IDataGateway in endpoints:
- Inject
IDataGatewayvia constructor - Create a data command using fluent builders with
DataStore,Path, andContainer - Execute via
_dataGateway.Execute<T>(command, ct) - Handle
IGenericResult<T>(checkIsFailure, accessValue)
From Reference.DataLayer Program.cs:141-168:
var directQuery = new QueryCommand<CustomerDto>("dbo.Customers")
{
Filter = new FilterExpression
{
Root = new FilterCondition
{
PropertyName = "IsActive",
Operator = FilterOperators.Equal,
Value = true
}
},
Ordering = new OrderingExpression
{
OrderedFields =
[
new OrderedField
{
PropertyName = "CustomerName",
Direction = SortDirections.Ascending
}
]
},
Paging = new PagingExpression
{
Skip = 0,
Take = 50
}
};From Reference.DataLayer Program.cs:66-91:
// Simple equality condition
var nameFilter = new FilterCondition
{
PropertyName = "CustomerName",
Operator = FilterOperators.Equal,
Value = "Acme Corp"
};
// Contains (LIKE) condition - operator knows its own SQL!
var searchFilter = new FilterCondition
{
PropertyName = "Description",
Operator = FilterOperators.Contains,
Value = "widget"
};
// Null check (no value needed)
var nullCheck = new FilterCondition
{
PropertyName = "DeletedAt",
Operator = FilterOperators.IsNull
// No Value - IsNull doesn't need one (RequiresValue = false)
};From Reference.DataLayer Program.cs:190-199:
// Insert - data is passed to constructor
var newCustomer = new CustomerDto
{
Id = 0,
CustomerName = "New Customer",
Email = "new@example.com",
IsActive = true,
CreatedDate = DateTime.UtcNow
};
var insertCommand = new InsertCommand<CustomerDto>("dbo.Customers", newCustomer);From Reference.DataLayer Program.cs:201-222:
// Update - data passed to constructor, filter via init
var updatedCustomer = new CustomerDto
{
Id = 1,
CustomerName = "Updated Customer",
Email = "updated@example.com",
IsActive = true,
CreatedDate = DateTime.UtcNow
};
var updateCommand = new UpdateCommand<CustomerDto>("dbo.Customers", updatedCustomer)
{
Filter = new FilterExpression
{
Root = new FilterCondition
{
PropertyName = "Id",
Operator = FilterOperators.Equal,
Value = 1
}
}
};From Reference.DataLayer Program.cs:224-236:
// Delete
var deleteCommand = new DeleteCommand("dbo.Customers")
{
Filter = new FilterExpression
{
Root = new FilterCondition
{
PropertyName = "IsActive",
Operator = FilterOperators.Equal,
Value = false
}
}
};From Reference.DataLayer Program.cs:121-126:
var query = new QueryCommandBuilder<CustomerDto>("dbo.Customers")
.Where("IsActive", FilterOperators.Equal, true)
.Where("Status", FilterOperators.NotEqual, "Deleted")
.OrderBy("CustomerName")
.Paging(skip: 0, take: 100)
.Build();DO NOT DO THIS:
// WRONG - Don't create service wrappers for IDataGateway
public interface ICustomerService
{
Task<IGenericResult<Customer>> GetCustomerAsync(int id);
Task<IGenericResult<IEnumerable<Customer>>> ListCustomersAsync();
Task<IGenericResult<Customer>> CreateCustomerAsync(Customer customer);
}
public class CustomerService : ICustomerService
{
private readonly IDataGateway _dataGateway;
public CustomerService(IDataGateway dataGateway)
{
_dataGateway = dataGateway;
}
// This is unnecessary abstraction - inject IDataGateway directly in endpoints!
}Why This Is Wrong:
- Adds unnecessary abstraction layer
- Hides the actual data operations being performed
- Creates more code to maintain
- Reduces visibility into what data is accessed
- Makes testing more complex (now need to mock the service too)
Correct Approach:
Inject IDataGateway directly in endpoints and create data commands in-place. This makes data operations explicit and testable.
All data commands return IGenericResult<T>. Always check for failure before accessing the value.
When result.IsSuccess is false, this indicates a database or infrastructure failure (connection error, timeout, SQL error). Return HTTP 500 with error details:
var result = await _dataGateway.Execute<IEnumerable<Team>>(command, ct);
if (!result.IsSuccess)
{
var errorMessage = result.CurrentMessage ?? "Unknown error";
NflLog.TeamsFetchFailed(_logger, errorMessage); // Log AND return
HttpContext.Response.StatusCode = 500;
await HttpContext.Response.WriteAsJsonAsync(
new ApiErrorResponse { Error = "Failed to fetch teams", Details = errorMessage }, ct);
return;
}When query succeeds but returns no data, return 200 OK with empty collection (for list endpoints):
var players = new List<PlayerStat>(result.Value ?? []);
await Send.OkAsync(players, ct); // Returns [] if no dataWhen querying for a specific item that doesn't exist:
var team = result.Value?.FirstOrDefault();
if (team == null)
{
NflLog.TeamNotFound(_logger, req.TeamId);
await Send.NotFoundAsync(ct);
return;
}
await Send.OkAsync(team, ct);Use a consistent error response format:
public sealed class ApiErrorResponse
{
public required string Error { get; set; }
public string? Details { get; set; }
}| Condition | HTTP Status | Response |
|---|---|---|
!result.IsSuccess (DB error) |
500 |
ApiErrorResponse with error details |
result.Value is empty list |
200 | Empty array []
|
result.Value?.FirstOrDefault() is null |
404 | Empty response |
| Success with data | 200 | The data |
Every data command must specify DataStoreName, PathName, and ContainerName using the fluent builders:
// Query - all three in From() call
var command = Query.From<Customer>("OrdersDb", "dbo", "Customers")
.Where(c => c.IsActive).Equal(true)
.Build();
// Insert/Update/Delete - fluent methods
var command = Insert.Into<Customer>("Customers")
.DataStore("OrdersDb")
.Path("dbo")
.Value(customer);The DataGateway:
- Receives the command
- Reads the
DataStoreNameproperty - Resolves the DataStore from
IDataStoreProvider - Uses
PathNameto locate the schema within the DataStore - Uses
ContainerNameto locate the specific table/view - Routes the command to the appropriate connection for execution
// CORRECT
public MyEndpoint(IDataGateway dataGateway)
{
_dataGateway = dataGateway;
}
// WRONG
public MyEndpoint(ICustomerService customerService)
{
_customerService = customerService;
}// CORRECT - command creation is explicit and visible using fluent API
var command = Query.From<Customer>("OrdersDb", "dbo", "Customers")
.Where(c => c.IsActive).Equal(true)
.Build();
var result = await _dataGateway.Execute<IEnumerable<Customer>>(command, ct);
// WRONG - hiding the command behind a service method
var result = await _customerService.GetActiveCustomersAsync();// CORRECT - check IsFailure before accessing Value
var result = await _dataGateway.Execute<IEnumerable<Customer>>(command, ct);
if (result.IsFailure)
{
// Handle error
return;
}
// WRONG - assuming success
var customers = result.Value; // Could throw if result failed// CORRECT - type-safe, IntelliSense support
var result = await _dataGateway.Execute<IEnumerable<Customer>>(command, ct);
var customers = result.Value; // IEnumerable<Customer>, no casting
// WRONG - dynamic/weakly typed
var result = await _dataGateway.ExecuteDynamic(command, ct);
var customers = (List<Customer>)result.Value; // Requires casting, runtime errors// CORRECT - explicit DataStore, Path, and Container
var command = Query.From<Customer>("OrdersDb", "dbo", "Customers").Build();
// For mutations - use fluent methods
var command = Insert.Into<Customer>("Customers")
.DataStore("OrdersDb")
.Path("dbo")
.Value(customer);
// WRONG - missing required parameters
var command = Query.From<Customer>("Customers").Build(); // Compile error - requires 3 parametersWhen testing code that uses IDataGateway, mock the gateway and verify commands:
[Fact]
public async Task ExecuteQueryCommandReturnsExpectedResult()
{
// Arrange
var mockGateway = new Mock<IDataGateway>();
var expectedCustomer = new CustomerDto { Id = 1, CustomerName = "Test" };
mockGateway
.Setup(g => g.Execute<IEnumerable<CustomerDto>>(
It.Is<QueryCommand<CustomerDto>>(cmd =>
cmd.DataStoreName == "OrdersDb" &&
cmd.PathName == "dbo" &&
cmd.ContainerName == "Customers"),
It.IsAny<CancellationToken>()))
.ReturnsAsync(GenericResult<IEnumerable<CustomerDto>>.Success([expectedCustomer]));
// Act - use fluent builder
var command = Query.From<CustomerDto>("OrdersDb", "dbo", "Customers")
.Where(c => c.Id).Equal(1)
.Build();
var result = await mockGateway.Object.Execute<IEnumerable<CustomerDto>>(command, CancellationToken.None);
// Assert
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldNotBeNull();
result.Value.First().CustomerName.ShouldBe("Test");
}Use domain services for:
- Business logic - Calculations, validations, domain rules
- Orchestration - Coordinating multiple operations
- Domain events - Publishing events based on domain state changes
- Complex workflows - Multi-step processes with business rules
Do NOT use domain services for:
- Simple data access - Use IDataGateway directly
- CRUD operations - Commands handle this
- Wrapping IDataGateway - This adds no value
The DataGateway pattern in FractalDataWorks provides:
- Direct data access - No unnecessary abstraction layers
- Command-based operations - Explicit, type-safe data commands
- DataStore routing - Automatic routing by DataStoreName/PathName/ContainerName
- Railway-Oriented - Consistent error handling with IGenericResult
- Testability - Mock IDataGateway, verify commands sent
Follow the Reference Solution examples for correct implementation patterns.
- DataSets - Logical data abstraction layer
- TypeCollections Overview - Type-safe lookup collections
- Reference Solution Data Layer - Working examples