LiteOrm supports dynamic table sharding through the IArged interface, suitable for tables split by dimensions like time, region, etc.
Implement the IArged interface, and the framework automatically calls the TableArgs property to get table routing parameters when executing SQL.
public interface IArged
{
string[] TableArgs { get; }
}
[Table("Logs_{0}")] // {0} will be replaced by TableArgs
public class Log : IArged
{
[Column("Id", IsPrimaryKey = true, IsIdentity = true)]
public long Id { get; set; }
[Column("Level")]
public string? Level { get; set; }
[Column("Message")]
public string? Message { get; set; }
[Column("CreateTime")]
public DateTime CreateTime { get; set; }
string[] IArged.TableArgs => new[] { CreateTime.ToString("yyyyMM") };
}
var log = new Log
{
Level = "INFO",
Message = "User logged in",
CreateTime = DateTime.Now
};
await logService.InsertAsync(log);
// Automatically routes to table Logs_202603
Specify the shard via the tableArgs parameter:
// Specify shard via tableArgs parameter
var logs = await logService.SearchAsync(
l => l.CreateTime >= startTime && l.CreateTime <= endTime,
tableArgs: new[] { "202603" }
);
var log = new Log
{
Level = "ERROR",
Message = "Payment failed",
CreateTime = new DateTime(2026, 3, 15)
};
// Uses IArged.TableArgs => Logs_202603 on insert
await logService.InsertAsync(log);
// Query single monthly shard
var marchLogs = await logService.SearchAsync(
l => l.Level == "ERROR",
tableArgs: new[] { "202603" }
);
Orders_{0}: shard by user suffix[Table("Orders_{0}")]
public class Order : IArged
{
[Column("Id", IsPrimaryKey = true, IsIdentity = true)]
public long Id { get; set; }
[Column("UserId")]
public long UserId { get; set; }
[Column("Amount")]
public decimal Amount { get; set; }
string[] IArged.TableArgs => new[] { (UserId % 10).ToString() };
}
This style fits scenarios where one data source contains many physical tables, for example:
Orders_0Orders_1Orders_9On insert, the framework reads IArged.TableArgs and routes UserId = 25 to Orders_5. On query, you can explicitly pick the shard with tableArgs: new[] { "5" } or WithArgs("5").
{0}.Orders: route by user into same-named tables under different databases/schemasPlaceholders are not limited to table suffixes. They can also appear in the database.table or schema.table position:
[Table("{0}.Orders")]
public class UserOrder : IArged
{
[Column("Id", IsPrimaryKey = true, IsIdentity = true)]
public long Id { get; set; }
[Column("UserId")]
public long UserId { get; set; }
[Column("Amount")]
public decimal Amount { get; set; }
string[] IArged.TableArgs => new[] { $"UserShard_{UserId % 4}" };
}
When UserId = 25, TableArgs[0] = "UserShard_1", so the final SQL identifier becomes:
UserShard_1.Orders
This is still TableArgs routing. The only difference is that the placeholder is placed in the database/schema position. It works best when:
IArged, tableArgs, WithArgs(...), and Expr.From<T>(...).Important:
{0}.Ordersdepends on provider support for cross-database or cross-schema access on the current connection.
If each shard requires a completely different connection string, prefer theDataSourceapproach below instead of pushing the database name intoTableArgs.
[Table("Sales_{0}_{1}")]
public class SalesRecord : IArged
{
[Column("Id", IsPrimaryKey = true, IsIdentity = true)]
public long Id { get; set; }
[Column("Region")]
public string? Region { get; set; }
[Column("Year")]
public int Year { get; set; }
[Column("Amount")]
public decimal Amount { get; set; }
string[] IArged.TableArgs => new[] { Region!, Year.ToString() };
}
Here, {0} and {1} map to TableArgs[0] and TableArgs[1]. For example:
var args = new[] { "US", "2025" };
// Resolves to table name: Sales_US_2025
By assigning different dimensions to different placeholder positions, you can pass structured parameters such as region + year directly instead of manually concatenating strings like "US_2025".
Service layer specifies shards via the tableArgs parameter of SearchAsync; DAO layer uses the WithArgs method.
// Specify shard via tableArgs parameter
var results = await salesService
.SearchAsync(s => s.Amount > 1000, tableArgs: new[] { "US", "2025" });
// Specify shard via WithArgs method
var results = await salesViewDAO
.WithArgs("US", "2025")
.Search(s => s.Amount > 1000)
.ToListAsync();
TableArgs PropagationTableArgs are not limited to just “the current table”. They propagate through scopes:
tableArgs, WithArgs(...), or Expr.From<T>(...), those arguments enter the current SQL scope.TableArgs.TableArgs on its own TableExpr or association expression, that explicit value overrides the inherited one.So when the same sharding dimensions apply across the whole query chain, you usually only need to specify the arguments once on the main table.
Security note: explicit
TableArgson aTableExproverride the inherited shard arguments currently stored inSqlBuildContext.
If your upper scope relies on those arguments to enforce tenant, shard, or data-range boundaries, a lower-levelTableExproverride can bypass that boundary. Use this carefully to avoid out-of-scope data access.
You need to query each shard individually and merge the results:
// Merge query results from multiple shards
var allLogs = new List<Log>();
for (int month = 1; month <= 12; month++)
{
var tableName = $"{month:D2}"; // 01, 02, ... 12 (Logs_ prefix is already defined in Table attribute)
var logs = await logService
.SearchAsync(l => l.Level == "ERROR", tableArgs: new[] { tableName });
allLogs.AddRange(logs);
}
IArged vs tableArgs Override Examplevar order = new Order
{
UserId = 25
};
// Automatically routes to Orders_5 on insert
await orderService.InsertAsync(order);
// Explicitly specifying tableArgs on query overrides auto-derived result
var archivedOrders = await orderService.SearchAsync(
o => o.UserId == 25,
tableArgs: new[] { "archive_5" }
);
The same rule applies when the explicit override is placed deeper in a TableExpr, subquery, or association expression: it replaces the inherited context value. In multi-tenant or scoped-data systems, make sure that override is intentional.
TableArgs in LambdaThis pattern comes from LiteOrm.Demo\Demos\ShardingQueryDemo.cs:
var sales = await salesService.SearchAsync(s =>
s.TableArgs == new[] { "202412" } && s.Amount > 40
);
Suitable for quickly querying a fixed month or a fixed shard.
tableArgsvar sales = await salesService.SearchAsync(
s => s.Amount > 100,
tableArgs: new[] { "202411" }
);
This pattern is the same as CountAsync(..., tableArgs: ...) in tests, suitable for unified control of shard parameters at the caller layer.
Expr.From<T>(...) to Specify Shardusing static LiteOrm.Common.Expr;
var sales = await salesService.SearchAsync(
From<SalesRecordView>("202411")
.Where(Prop("Amount") > 100)
.OrderBy(("Amount", false))
.Section(0, 3)
);
This pattern also comes from Demo, suitable for combining complex queries, sorting, and pagination.
using static LiteOrm.Common.Expr;
var sales = await salesService.SearchAsync(
From<SalesRecord>("US", "2025")
.Where(Prop("Amount") > 100)
.Section(0, 20)
);
For [Table("Sales_{0}_{1}")], "US" replaces {0} and "2025" replaces {1}.
This is clearer than passing a single concatenated string such as "US_2025", and it makes region, year, and other dimensions easier to reuse independently at the call site.
Different tables can also share the same TableArgs array while consuming different placeholder positions. For example:
[Table("Table1_{0}")]
public class Table1Row
{
}
[Table("Table2_{1}")]
public class Table2Row
{
}
Pass the argument array only once on the main table:
using static LiteOrm.Common.Expr;
var args = new[] { "TenantA", "202501" };
var expr = From<Table1Row>(args)
// Table2Row in the same scope or a child scope keeps using args
// unless it explicitly sets its own TableArgs.
.Where(Exists<Table2Row>(t => true));
Then:
Table1_{0} uses args[0], so the resolved table name is Table1_TenantATable2_{1} uses args[1], so the resolved table name is Table2_202501In other words, one array can feed different tables with different parameters, and each table only consumes the placeholder positions it references. This is especially useful for combinations such as tenant + month or business-line + region.
| Source | Priority | Description |
|---|---|---|
IArged.TableArgs |
Automatic | Entity implements interface, auto-used on insert/update |
tableArgs parameter / WithArgs |
Explicit | Explicit on query, overrides IArged |
For query chains, keep this additional rule in mind:
TableArgs determined by the main table propagate to tables in the same scope and in child scopes.TableArgs, those values override the inherited ones.Note: LiteOrm cannot automatically know which shards exist. Cross-shard queries require iterating through possible shards at the application layer and merging results.
DataSourceTableArgs answers “which physical table or placeholder-based database name should this operation hit at runtime?”
DataSource answers “which configured connection should this entity use by default?”
[Table("Orders", DataSource = "OrderDbEast")]
public class EastOrder : ObjectBase
{
[Column("Id", IsPrimaryKey = true, IsIdentity = true)]
public long Id { get; set; }
}
[Table("Orders", DataSource = "OrderDbWest")]
public class WestOrder : ObjectBase
{
[Column("Id", IsPrimaryKey = true, IsIdentity = true)]
public long Id { get; set; }
}
Configuration:
{
"LiteOrm": {
"Default": "OrderDbEast",
"DataSources": [
{
"Name": "OrderDbEast",
"ConnectionString": "Server=east;Database=OrdersEast;...",
"Provider": "MySqlConnector.MySqlConnection, MySqlConnector"
},
{
"Name": "OrderDbWest",
"ConnectionString": "Server=west;Database=OrdersWest;...",
"Provider": "MySqlConnector.MySqlConnection, MySqlConnector"
}
]
}
}
Key characteristics:
DataSource is static metadata on [Table(..., DataSource = "...")].DataSourceIf the target database must be chosen at runtime based on the current user, tenant, or request context, [Table(DataSource = "...")] is not enough because that metadata is static.
In that case, the better fit is to override the DataSource property in a custom DAO.
DAOBase provides this default implementation:
protected virtual string DataSource => TableDefinition.DataSource;
You can replace it with a runtime decision:
[AutoRegister(Lifetime.Scoped)]
public class UserOrderDAO : ObjectDAO<UserOrder>
{
private readonly IUserContext _userContext;
public UserOrderDAO(IUserContext userContext)
{
_userContext = userContext;
}
protected override string DataSource
=> $"OrderDb_{_userContext.UserId % 4}";
}
What this means:
Orders.OrderDb_0, OrderDb_1, OrderDb_2, or OrderDb_3.GetDaoContext() and SqlBuilder follow the overridden DataSource.This pattern is especially useful when:
Boundary: this is a DAO-layer dynamic database-routing pattern.
If you are using the genericEntityService<T>/IEntityService<T>flow, you will usually wrap it with a custom service or factory that chooses the appropriate DAO first.
DataSource with TableArgsIf a business area already lives in its own data source and still needs internal table sharding, combine both:
[Table("Logs_{0}", DataSource = "LogDB")]
public class Log : IArged
{
[Column("CreateTime")]
public DateTime CreateTime { get; set; }
string[] IArged.TableArgs => new[] { CreateTime.ToString("yyyyMM") };
}
This means:
LogDB connection.Logs_202603, Logs_202604, and so on.{0}.table / tableArgs vs DataSource| Approach | Routing granularity | Runtime-dynamic? | Typical form | Best fit |
|---|---|---|---|---|
Orders_{0} / {0}.Orders + TableArgs |
table name / database-name placeholder | Yes | [Table("Orders_{0}")], [Table("{0}.Orders")] |
Per-user, per-month, per-region dynamic routing on the same accessible connection scope |
[Table(..., DataSource = "...")] |
configured connection | No (fixed per entity) | [Table("Orders", DataSource = "OrderDbEast")] |
Fixed split by business domain, hot/cold storage, or known tenant groups |
override DAO DataSource |
configured connection | Yes | protected override string DataSource => ... |
Per-user, per-tenant, or per-request dynamic connection selection |
DataSource + TableArgs |
choose connection first, then choose table | Partially | [Table("Logs_{0}", DataSource = "LogDB")] |
A dedicated business database that still needs table sharding inside it |
As a rule of thumb:
TableArgs.DataSource.DataSource.{
"LiteOrm": {
"Default": "WriteDB",
"DataSources": [
{
"Name": "WriteDB",
"ConnectionString": "Server=master;...",
"Provider": "...",
"ReadOnlyConfigs": [
{
"ConnectionString": "Server=replica01;..."
},
{
"ConnectionString": "Server=replica02;...",
"PoolSize": 10
}
]
}
]
}
}
TableArgs is correctly assigned before insert