| name | ebuilder-library-task-csharp-patterns |
| description | C# implementation playbook for standard eBuilder library task handlers in dll/. USE FOR: implement or refactor TaskLibraryHandler classes, parse input with ParseInputParams<T>, handle transactions, choose between ConnectDataSource (write tasks) and ConnectDataSourceWithoutTransaction (readonly/query-only tasks), define query result DTOs, and shape TaskLibraryHandlerResult responses. DO NOT USE FOR: task YAML schema authoring in configs/task.*.yml, SQL YAML in configs/sql.*.yml, UI component work, or singleton-service recommendation patterns (use ebuilder-singleton-service-recommendation-patterns). |
eBuilder Library Task C# Playbook
Purpose
Use this skill when type: library task work requires C# implementation or refactoring in dll/.
This skill covers:
- handler class structure
- input parsing and validation
- transaction-safe data access patterns
- handler response semantics
Scope
- Primary folder:
dll/
- Related contract points:
configs/task.*.yml activator.assembly and activator.class
- Handler base type:
TaskLibraryHandler
Resolve Engine Source Before API Lookup
When handler code needs eBuilder engine APIs such as framework interfaces, services, or base implementations:
- Inspect the current app's
dll/Directory.Build.targets file first.
- Read each
Reference/HintPath entry for engine assemblies such as EBuilder.Core.dll or EBuilder.ODBC.dll.
- Convert a path like
.../ebuilder-engine/EBuilder.Engine/bin/Debug/net10.0/EBuilder.Core.dll into the engine repository root .../ebuilder-engine/.
- Search for the interface or service implementation inside that resolved engine repository, not across the whole workspace.
This keeps API lookup grounded to the exact engine build the app DLL references.
Minimal Handler Sample
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using EBuilder.Core.Models;
using EBuilder.Core.Providers;
using Microsoft.AspNetCore.Http;
namespace Task.Project.Namespace;
public class TaskName : TaskLibraryHandler
{
public TaskName()
: base() { }
public override Task<TaskLibraryHandlerResult> Execute(object parameters, List<IFormFile> files)
{
return Task.FromResult(new TaskLibraryHandlerResult
{
Body = new { data = "data in json response" },
StatusCode = HttpStatusCode.OK
});
}
}
Input Parsing And Validation
Prefer typed parsing with ParseInputParams<T>(parameters) and validation attributes.
Validation Attributes To Prefer
- Use
EBInputParamRangeAttribute(min, max) for numeric property-level bounds.
- Use
EBInputParamItemRangeAttribute(min, max) for numeric primitive array item bounds.
- Use
EBInputParamItemAllowEmptyAttribute(false) for primitive string-array item empty checks.
using EBuilder.Core.Validation;
[EBInputParamClass]
public class InputParams
{
[EBInputParamProp("percent")]
[EBInputParamRangeAttribute(0, 100)]
public double? Percent { get; set; }
[EBInputParamProp("nonNullableString")]
[EBInputParamNullable(false)]
[EBInputParamRequired]
public string NonNullableString { get; set; }
[EBInputParamProp("scores")]
[EBInputParamItemRangeAttribute(0, 100)]
public double[] Scores { get; set; }
[EBInputParamProp("tags")]
[EBInputParamItemAllowEmptyAttribute(false)]
public string[] Tags { get; set; }
[EBInputParamProp("nullableInt")]
public int? NullableInt { get; set; }
[EBInputParamProp("dateOnly")]
[EBInputParamDateTime(dateOnly: true, useLocalTimeZone: true)]
public DateTime? DateOnly { get; set; }
}
public override async Task<TaskLibraryHandlerResult> Execute(object parameters, List<IFormFile> files)
{
var input = ParseInputParams<InputParams>(parameters);
return new TaskLibraryHandlerResult
{
Body = new { input.Percent, input.NonNullableString, input.Scores, input.Tags, input.NullableInt, input.DateOnly }
};
}
Define EBQueryResultType for query in C#
When using databaseProvider.Query<ReturnObjectClass> or DatabaseService.Query<ReturnObjectClass>, we should always create DTO class for the result following this pattern
namespace base_namespace.Models
[EBQueryResultType]
public class TdTodoDto
{
[EBColumnName("id")]
public int? Id { get; set; }
[EBColumnName("maxDate")]
public DateTime? MaxDate { get; set; }
[EBColumnName("maxRemain")]
public int? MaxRemain { get; set; }
}
For querying objects DTO, remember to use EBQueryResultType and EBColumnName attributes to define the shape of the result set,
also the ...Dto classes should be kept in a separate .cs files in /Models directory.
Transaction Pattern
Use eBuilder datasource helpers and provider APIs for transaction-safe SQL work.
Use databaseProvider to work with eb-sql commands (support all $EB__ symbols) directly and use DatabaseService to work with defined eb-query and eb-mutation which are defined in configs/sql._.yml files.
var (connection, transaction, dataSourceConfig, queryBuilderProvider) = ConnectDataSource();
var databaseProvider = GetDatabaseProvider(dataSourceConfig.Type);
using (connection)
using (transaction)
{
try
{
var allItemIdsInSet = await databaseProvider.Query<int>(
AppContext,
dataSourceConfig,
connection,
transaction,
queryBuilderProvider,
"""
SELECT id_item
FROM ec_item
WHERE id_set = ${setId}
AND is_active = $EB_CONSTANT(YES)
""",
new Dictionary<string, object>() { ["setId"] = setId }
);
var listTdTodos = await databaseProvider.Query<TdTodoDto>(
AppContext,
dataSourceConfig,
connection,
transaction,
queryBuilderProvider,
"""
SELECT
maxremain AS "maxRemain",
maxdate AS "maxDate",
id AS "id"
FROM td_todo
WHERE tid = ${tId} AND $EB_IN_ARRAY_ARG(id, ${validNextTodos})
ORDER BY listorder ASC
""",
new Dictionary<string, object> { ["tId"] = inputParams.TodoTId, ["validNextTodos"] = validNextTodos }
);
var listTdTodosFromEbQuery = await DatabaseService.Query<TdTodoDto>(
AppContext,
"query-get-next-todo",
new Dictionary<string, object> { ["tId"] = inputParams.TodoTId, ["validNextTodos"] = validNextTodos },
dataSourceConfig,
connection,
transaction,
queryBuilderProvider
);
var newCatResults = await databaseProvider.ExecuteSQLStep(
new SqlStepDefinitionMap
{
Command = """
INSERT INTO td_categories
(
subtitle_de,
subtitle_en,
subtitle_fr,
subtitle_it,
isactive,
kindof,
created_at
)
VALUES
(
${subtitle_de},
${subtitle_en},
${subtitle_fr},
${subtitle_it},
${isactive},
${kindof},
$EB_DATETIME_NOW
)
""",
Save = "last-insert-id",
Extra = new Dictionary<string, string> { ["identityCol"] = "catId" },
},
AppContext,
dataSourceConfig,
connection,
transaction,
queryBuilderProvider,
new Dictionary<string, object>
{
["subtitle_de"] = tdCategory["subtitle_de"],
["subtitle_en"] = tdCategory["subtitle_en"],
["subtitle_fr"] = tdCategory["subtitle_fr"],
["subtitle_it"] = tdCategory["subtitle_it"],
["isactive"] = int.Parse(tdCategory["isactive"]),
["kindof"] = int.Parse(tdCategory["kindof"])
}
);
if (!newCatResults.TryGetValue("default", out var defaultNewCatResults) || defaultNewCatResults == null)
{
transaction.Rollback();
return new()
{
ErrorMessage = "error_checklist_import_category_failed",
StatusCode = HttpStatusCode.InternalServerError,
};
}
categoryId = Convert.ToInt32(defaultNewCatResults);
var (stepsResults, _) = await DatabaseService.Execute(
AppContext,
"mutation-import-sector",
new Dictionary<string, object> { ["name"] = name },
dataSourceConfig,
connection,
transaction,
queryBuilderProvider
);
if (
stepsResults is not IDictionary<string, object> stepsResultsDict
|| !stepsResultsDict.TryGetValue("newSectorId", out var newSectorId)
|| newSectorId == null
)
{
throw new InvalidOperationException("mutation-import-sector doesn't return valid 'newSectorId' step value");
}
var resultNewSectorId = newSectorId.ToSafeLong();
transaction.Commit();
return new TaskLibraryHandlerResult { Body = new { rows } };
}
catch
{
transaction.Rollback();
throw;
}
}
Readonly / Query-Only Pattern
When the task only reads data and performs no writes, use ConnectDataSourceWithoutTransaction instead of ConnectDataSource.
This avoids opening and rolling back an unnecessary database transaction, which improves performance.
The destructuring tuple returns three values (no transaction):
var (connection, dataSourceConfig, queryBuilderProvider) = ConnectDataSourceWithoutTransaction();
var databaseProvider = GetDatabaseProvider(dataSourceConfig.Type);
using (connection)
{
var items = await databaseProvider.Query<ItemDto>(
AppContext,
dataSourceConfig,
connection,
null,
queryBuilderProvider,
"""
SELECT id AS "id", name AS "name"
FROM ec_item
WHERE id_set = ${setId} AND is_active = $EB_CONSTANT(YES)
""",
new Dictionary<string, object> { ["setId"] = inputParams.SetId }
);
var listTdTodos = await DatabaseService.Query<TdTodoDto>(
AppContext,
"query-get-next-todo",
new Dictionary<string, object> { ["tId"] = inputParams.TodoTId },
dataSourceConfig,
connection,
null,
queryBuilderProvider
);
return new TaskLibraryHandlerResult { Body = new { items } };
}
Decision rule:
| Scenario | Method |
|---|
| Task writes to the database (INSERT / UPDATE / DELETE) | ConnectDataSource — use transaction |
| Task only reads data (SELECT only) | ConnectDataSourceWithoutTransaction — pass null for transaction |
Related Skill
For recommendation patterns that move business logic from handlers into EBSingletonService classes, use:
.github/skills/ebuilder-singleton-service-recommendation-patterns/SKILL.md
Handler Result Semantics
- Use
TaskLibraryHandlerResult.Body for normal JSON response payload (result.data).
- Use
ErrorMessage with appropriate StatusCode for predictable frontend error handling.
- For file responses, use stream fields (
BodyStreamMemory, BodyStreamFilePath, BodyStream) based on download behavior needs.
Checklist
- Task
activator.class maps to a real handler class in dll/.
- Input mapping and validation attributes match task parameter names.
- Transaction boundaries are explicit where writes occur — use
ConnectDataSource.
- Read-only / query-only tasks use
ConnectDataSourceWithoutTransaction and pass null for transaction.
- Handler code focuses on orchestration and response mapping.
- Handler returns predictable status and payload/error semantics.