Artisan Way of Data Reply - lobodava/artisan-orm GitHub Wiki
The DataReply pattern lets a stored procedure communicate business outcomes — validation failures, missing entities, concurrency conflicts, or access denial — separately from infrastructure errors such as timeouts, deadlocks, and network failures. A procedure that uses this protocol returns a status result set before its data. The C# helper turns a non-OK status into DataReplyException, with structured messages when supplied.
Originally introduced in Artisan.Orm 1.1. For the design rationale and evolution of the pattern, see The Artisan Way of Data Reply.
The behavior below describes the source being prepared for 4.1.0, including explicit Ok handling. This is not an announcement that a 4.1.0 package is already published. Check an installed package's version and release notes before relying on these fixes; see CHANGELOG.md.
DataReply is optional. Use it when a procedure's business validation needs structured status/message details. Ordinary repositories can return entities or scalars directly, and an application can use its own result and HTTP error model. The protocol does not require every data operation to return a wrapper.
Each participating procedure starts with a result set containing one status string in its first row and column. What follows depends on that status:
| Status | Following result sets |
|---|---|
Ok, case-insensitively |
The payload starts in the very next result set; no empty message set is required. |
| A defined non-OK status | An optional message result set; the helper reads it and then throws. If no set follows, it still throws with that status. |
| SQL NULL, empty, or whitespace-only status | Treated as no failure; the helper advances once to the payload. |
A message row has four columns in this exact order:
| Ordinal | Field | SQL/CLR contract |
|---|---|---|
| 0 | Code |
Non-null string |
| 1 | Text |
Nullable string |
| 2 | Id |
Nullable bigint / long?
|
| 3 | Value |
Nullable value / object?
|
Names make the SQL clearer, but the mapper reads by ordinal. Include typed null columns for unused fields; a two-column (Code, Text) result does not satisfy the mapper.
For example, this complete procedure reads the existing sample dbo.Users table. Its successful projection matches the sample UserMapper through RowVersion:
CREATE PROCEDURE dbo.GetUserWithReply
@Id int
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1 FROM dbo.Users WHERE Id = @Id)
BEGIN
SELECT N'Missing';
SELECT N'USER_NOT_FOUND' AS Code,
N'The requested user does not exist' AS Text,
CAST(@Id AS bigint) AS Id,
CAST(NULL AS sql_variant) AS Value;
RETURN;
END;
SELECT N'Ok';
SELECT Id, [Login], Name, Email, RowVersion
FROM dbo.Users
WHERE Id = @Id;
END;The existence check and read are illustrative; apply the application's transaction/isolation policy when the result must be stable across concurrent changes. A save procedure can use the same status/message protocol around its own write logic.
RepositoryBase.CheckForDataReplyException reads the current status result set and advances past it exactly once on success. The next Read* call reads the first payload; do not insert another NextResult() between the check and that read.
This method belongs in a repository derived from RepositoryBase, using the sample User model and mapper:
public User? GetUserWithReply(int id)
{
return GetByCommand(cmd =>
{
cmd.UseProcedure("dbo.GetUserWithReply");
cmd.AddIntParam("@Id", id);
return cmd.GetByReader(reader =>
{
CheckForDataReplyException(reader);
return reader.ReadTo<User>();
});
});
}Call the check only for a procedure that emits the status header. Applying it to an ordinary entity result set would consume actual data as a status. A DataReplyException exposes Status and Messages; the application decides how those map to its UI or HTTP response. A domain status is not itself an HTTP status code.
The helper is synchronous. There is no CheckForDataReplyExceptionAsync in the current source, and GetByReaderAsync takes a synchronous projection. Do not pass an async projection that outlives the reader. If status and payload traversal must be asynchronous and cancellable, own the reader and implement the same protocol using ReadAsync and NextResultAsync with the token.
The current enum contains:
| Name | Intended meaning |
|---|---|
Ok |
Success |
Fail |
General business failure |
Missing |
Requested entity was not found |
Validation |
Input or business validation failed |
Concurrency |
Concurrent-change conflict |
Denial |
Requested operation was denied |
Error |
Error reported through the reply contract |
Parsing is case-insensitive. Null, empty, and whitespace-only strings produce no status. An unknown status name throws InvalidCastException; it does not become Fail. Conflict and Unauthorized are not enum members. Send the defined names instead of inventing new wire values.
In 4.1.0, ParseStatus rejects undefined numbers such as 999 and comma-separated combinations such as Fail, Missing, even if their combined value names another status. Defined numeric values remain accepted for compatibility; send one defined name for the SQL protocol. Use application-specific message Code values for more detail. A status-only DataReplyException.Message now includes its status instead of being empty.
C# can construct the same result objects directly, for example new DataReply<User>(DataReplyStatus.Missing) or new DataReply(DataReplyStatus.Validation, "INVALID_NAME", "Name is required"). It can also throw new DataReplyException(DataReplyStatus.Validation, "INVALID_NAME", "Name is required"). T-SQL emits the result sets; it does not instantiate a CLR exception.
T-SQL RAISERROR and THROW normally surface through the provider as SqlException. DataReply keeps defined business outcomes distinguishable through a separate status and message contract, while infrastructure failures can remain SQL exceptions. The helper still uses the .NET exception channel for non-OK replies: callers must handle DataReplyException deliberately.
Use DataReply when the relevant business checks live in T-SQL. Validate input in C# when the necessary data is already available there. Use SQL errors for failures that should abort execution. These approaches can be combined without treating every SQL exception as a validation result.
The sample dbo.SaveUser has two TVPs: the six-column @User value and a separate @RoleIds collection. Its repository passes both and consumes the status before reading the saved user. Omitted SQL TVP parameters are empty tables, so omitting @RoleIds can clear the user's roles; it does not mean "leave existing roles unchanged." Use the current repository and its matching SQL types rather than the historical four-column teaching mapper in Article 2.
The batch procedure dbo.SaveUsers also validates duplicate login, name, and email values independently in 4.1.0, including batches of new negative-ID rows. The correction belongs to the sample database and requires explicitly updating that procedure; installing the NuGet package does not deploy it.
DataReplyMessage exposes public fields. System.Text.Json does not include those fields by default, and the historical DataMember annotations do not configure it. An ASP.NET Core MVC application serializing the library types directly can explicitly configure the following policy in Program.cs:
using System.Text.Json;
using System.Text.Json.Serialization;
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.IncludeFields = true;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.JsonSerializerOptions.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
});This produces camel-case member names and enum strings, includes message fields, and omits null values. It retains non-null default values such as DataReply<int>.Data == 0; message Id is a JSON number. Messages may be absent even on a failure, so client code must handle missing/null messages. An in-memory informational new DataReply("Saved") can also have a message without a Code, unlike the SQL message mapper's non-null Code contract.
IncludeFields affects other public fields handled by those controllers too. Map replies to application response DTOs if a narrower serialization contract is preferred. The configuration is an application decision, not an implicit library behavior. See System.Text.Json field serialization.
HTTP is an application-level protocol. Appropriate HTTP statuses and structured bodies work together: for example, the article's current policy pairs Validation with HTTP 400, Missing with 404, Concurrency with 409, and Denial with 403. These are example mappings, not a mandatory Artisan.Orm policy. Clients must inspect both the HTTP status and the reply body, including validation details in HTTP error responses. The current article has matched controller, JSON and fetch examples.
Keep unexpected exception messages and stack traces in server logs and send safe client messages. Let cancellation propagate when it should cancel the operation. The test-only Tests/DataServices/DataServiceBase.cs now lets cancellation propagate and safely bounds null/short stack traces. It still includes diagnostic details in test replies, which is not the recommended public web error policy. The article's current service example shows the safer boundary without changing the library's DataReply types.
See also: