How-to guide

Repository mode

Declare a repository interface and let Kiwi ORM derive the queries from the method names, with pagination and dependency-injection support.

ORM Current 6 min read Updated

Prerequisites

  • The Enterprise edition of Kiwi ORM, and an entity with a primary key

Note

Commercial feature. This capability is part of the Kiwi ORM Enterprise edition. The source-available core does not include it. See Editions and features.

Repository mode gives you a clean, standard data-access contract. Inspired by Spring Data JPA, you declare an interface and Kiwi ORM implements it. It is the right fit where a standardised, testable abstraction matters, and it is not the default for every operation.

Declare a repository#

Extend IKiwiRepository<TEntity, TKey> and add methods whose names describe the query:

csharp
using Kiwify.Kiwi.ORM.Repository.Interfaces;

public interface ICustomerRepository : IKiwiRepository<Customer, int>
{
    // derived reads
    IEnumerable<Customer> FindByCity(string city);
    IEnumerable<Customer> FindByCityAndIsActiveTrue(string city);          // "True" = boolean predicate, no parameter
    IEnumerable<Customer> FindByCreditLimitGreaterThanAndCountry(decimal limit, string country);
    IEnumerable<Customer> FindByNameStartingWith(string prefix);
    Customer? FindOneByEmail(string email);

    // derived count, exists and aggregates
    int CountByCity(string city);
    bool ExistsByEmail(string email);
    decimal SumCreditLimitByCity(string city);

    // derived writes
    int UpdateCreditLimitById(int id, decimal creditLimit);
    int DeleteByEmail(string email);
}

The SQL comes from the method name. Prefixes such as findBy, countBy, existsBy, sumBy, deleteBy and updateXBy, and operator suffixes such as And, GreaterThan, Between and StartingWith, are parsed once and cached. Property paths can follow relationships (for example FindByManagerFirstName).

Inherited operations#

Every repository also gets the standard operations: Save and SaveAll (insert or update by key), FindById, FindAll (optionally sorted or paged with a Pageable), FindAllById, Count, ExistsById, Delete, DeleteById, DeleteAll and batch deletes, plus GetReference(id).

Warning

GetReference(id) returns an entity with only its primary key populated and makes no database call. It is not a lazy-loading proxy. Use it only to assign a foreign-key association (for example order.Employee = repo.GetReference(employeeId)), never to read other properties.

Audit repository changes#

Repository mode runs through the same pipeline as the other modes, so the audit trail described in Audit data access covers repository writes with no extra code. Mark the entity with [Auditable], enable an audit adapter on the context, and every Save, Delete and derived write is recorded with the acting user, the old and new column values, and [Sensitive] columns redacted.

To say why a change was made, pass an AuditProperties object (Reason and TicketNumber) as the last parameter of a write. The inherited writes (Save, SaveAll, SaveAsync, SaveAllAsync, SaveAndFlush, Delete, DeleteById, DeleteAll, DeleteAllById, DeleteInBatch) each have an overload that takes it. For derived writes (updateXBy, deleteBy, increment and decrement), add an AuditProperties parameter at the end of the method declaration and the proxy picks it up.

csharp
[Auditable]
[TableInfo("customers", RequireAuditReason = true)]
public class Customer : Entity<Customer> { /* ... */ }

public interface ICustomerRepository : IKiwiRepository<Customer, int>
{
    // a derived write that accepts audit properties as its last parameter
    int UpdateCreditLimitById(int id, decimal creditLimit, AuditProperties audit);
}

var why = new AuditProperties
{
    Reason = "Credit review approved by the account owner",
    TicketNumber = "CASE-1042"
};

customers.Save(customer, why);                              // inherited write
customers.UpdateCreditLimitById(42, 5000m, why);            // derived write
customers.DeleteById(42, why);

Things to know:

  • A reason can be required. If the table has RequireAuditReason = true and you call a write without a reason, it fails with an InvalidOperationException before it reaches the database.
  • Audited tables always load the rows first. For a table that is [Auditable], a set-based update or delete fetches the affected rows and changes them through the entity pipeline, so each row's old and new values can be recorded. Without auditing, a repository may issue a single direct statement instead. Expect a read before the write on audited tables.
  • Soft deletes are recorded as such. If the table has a soft-delete column, the audit shows a SoftDelete operation.

Create and use it#

csharp
var factory = new KiwiRepositoryFactory(db);          // Kiwify.Kiwi.ORM.Repository.Proxy
var customers = factory.Create<ICustomerRepository>();

var london = customers.FindByCityAndIsActiveTrue("London");
var page   = customers.FindAll(pageable);

Because the repository is an interface, you can substitute a fake in unit tests.

Register with dependency injection#

csharp
services.AddKiwiRepositoriesFromAssembly<SalesContext>(typeof(ICustomerRepository).Assembly);
// or
services.AddKiwiRepository<SalesContext, ICustomerRepository>();

The Kiwify.Kiwi.ORM.Repository.DependencyInjection package also offers AddKiwiRepositoriesFromEntryAssembly.

Custom implementations#

When a query is too specific for a method name, write it yourself. Put the hand-written methods in their own interface, implement that interface in a class, and have your repository interface extend both.

csharp
using Kiwify.Kiwi.ORM.Core.Context;
using Kiwify.Kiwi.ORM.Repository.Attributes;
using Kiwify.Kiwi.ORM.Repository.Interfaces;

// 1. The hand-written methods, with the attribute naming the class that implements them
[KiwiRepositoryImplementation(typeof(CustomerReportingRepository))]
public interface ICustomerReporting
{
    IEnumerable<Customer> FindTopSpenders(int year, int count);
    decimal TotalCreditByCountry(string country);
    void CloseAccount(Customer customer, string reason, string ticket);
}

// 2. The implementation: a public parameterless constructor and an optional Initialize(DatabaseContext)
public class CustomerReportingRepository : ICustomerReporting
{
    private DatabaseContext _db = null!;

    public void Initialize(DatabaseContext db) => _db = db;   // called with the current context

    public IEnumerable<Customer> FindTopSpenders(int year, int count)
    {
        var sql = @"
            SELECT {{TOP: @count}} *
            FROM {{T: Customers}}
            WHERE {{DATEPART: year, JoinedOn}} = @year
            ORDER BY {{C: TotalSpend}} DESC {{/TOP}}";

        return _db.ExecuteRead<Customer>(sql, new { year, count });
    }

    public decimal TotalCreditByCountry(string country) =>
        _db.ExecuteScalar<decimal>(
            "SELECT SUM(CreditLimit) FROM Customers WHERE Country = @country", new { country });

    public void CloseAccount(Customer customer, string reason, string ticket)
    {
        customer.IsActive = false;
        _db.Update(customer)                       // goes through the entity pipeline, so it is audited
           .WithAuditReason(reason)
           .WithTicketNumber(ticket)
           .Execute();
    }
}

// 3. The repository interface extends the standard contract and the custom one
public interface ICustomerRepository : IKiwiRepository<Customer, int>, ICustomerReporting
{
    IEnumerable<Customer> FindByCity(string city);          // still derived from the name
}

Use it like any other repository. Derived methods are generated, and the custom methods call your class:

csharp
var customers = factory.Create<ICustomerRepository>();

var london = customers.FindByCity("London");                 // generated from the name
var top    = customers.FindTopSpenders(2026, 10);            // your code
customers.CloseAccount(customer, "Customer request", "CASE-1042");

How the implementation is found:

  • With the attribute. [KiwiRepositoryImplementation(typeof(...))] goes on the custom interface, not on the class. The named class must implement that interface.
  • Without the attribute. Kiwi ORM looks for exactly one class that implements the custom interface. If it finds none, the interface is simply treated as having no custom part. If it finds more than one, it fails with an "ambiguous implementations" error, so add the attribute.
  • Construction. The class needs a public parameterless constructor. If it has a public Initialize(DatabaseContext) method, that is called with the current context each time, which is how your code gets the connection, transaction and audit settings.

Raw SQL in a custom method follows the usual rules, including the portable {{...}} KMQL markup. Writes that go through the entity pipeline, as CloseAccount does, are audited like any other write. See Audit repository changes.

Performance#

Generated queries run through the same cached, typed pipeline as Raw SQL mode, so the abstraction adds no execution overhead. See Performance for measured results against Dapper and Entity Framework Core. A SQL cache keeps parsed method names and generated statements, with a monitor (SqlCacheMonitor) to inspect it.

Known behaviour#

  • Declare count methods as int. A long return type currently fails inside the proxy (an Int32 to Int64 cast), so use int as in the example above.

Expected result

  • A typed, unit-testable repository whose queries are generated from method names