Creating Custom DB Provider

It’s a bit limited right now—Altruist is still in its early phase. Currently, ScyllaDB is the only persistance provider we support. However, the framework is designed to be extensible, and we plan to add more database options over time. In the meantime, if you need a different solution, you can extend the framework by writing your own custom database provider. This guide will walk you through the process step by step.


This is not a beginner guide.


Prerequisites

  1. Implementing a Custom Provider: If you're working with an SQL-based database, you’ll need to implement the ILinqDatabaseProvider interface. This interface includes essential methods for interacting with the database.
  2. Creating a Vault & Factory: The vault is the key abstraction that represents your model data. While the factory streamlines the creation of these vaults.
  3. Setting Up Configuration and Tokens: You'll need to set up a custom configuration for your provider and create a token for it. Altruist will be able to switch between multiple DB providers using the token.
  4. Connecting to the Database: You'll need to create a connection setup to initialize the database connection and configure services.

You are going to create this structure

src
MyDB
Extension.cs
MyDB.cs
Strategy.cs
Program.cs

Step 1: Implementing the ILinqDatabaseProvider (MyDB.cs)

If you're working with a SQL-based database, implement the ILinqDatabaseProvider interface, which extends from IGeneralDatabaseProvider. The key methods you need to implement are:

public interface IGeneralDatabaseProvider
{
    Task CreateTableAsync<TVaultModel>(IKeyspace? keyspace = null) where TVaultModel : class, IVaultModel;
    Task CreateTableAsync(Type entityType, IKeyspace? keyspace = null);
    Task CreateKeySpaceAsync(string keyspace, ReplicationOptions? options = null);
    Task ChangeKeyspaceAsync(string keyspace);
}

public interface ILinqDatabaseProvider : IGeneralDatabaseProvider
{
    DbContext Context { get; }
    Task<IEnumerable<TVaultModel>> QueryAsync<TVaultModel>(Expression<Func<TVaultModel, bool>> filter) where TVaultModel : class, IVaultModel;
    Task<TVaultModel?> QuerySingleAsync<TVaultModel>(Expression<Func<TVaultModel, bool>> filter) where TVaultModel : class, IVaultModel;
    Task<int> UpdateAsync<TVaultModel>(Expression<Func<SetPropertyCalls<TVaultModel>, SetPropertyCalls<TVaultModel>>> setPropertyCalls) where TVaultModel : class, IVaultModel;
    Task<int> DeleteAsync<TVaultModel>(Expression<Func<TVaultModel, bool>> filter) where TVaultModel : class, IVaultModel;
    Task<int> DeleteSingleAsync<TVaultModel>(TVaultModel model) where TVaultModel : class, IVaultModel;
    Task<int> DeleteMultipleAsync<TVaultModel>(TVaultModel model) where TVaultModel : class, IVaultModel;
}
  • CreateTableAsync: Used to create a table for your data model
  • CreateKeySpaceAsync: Used to create a keyspace in the database.
  • QueryAsync: Executes a query to retrieve data from the database based on a given filter.
  • UpdateAsync: Allows updating data models using LINQ expressions.

Note:

In most cases, you should be able to leverage EF Core functionalities, including object mapping, as long as your models are properly annotated with EF Core attributes. However Altruist introduces the [Vault(StoreHistory: true)] attribute to manage historical data storage, and If you want to fully integrate with Altruist, you must handle this correctly.


When StoreHistory=true, two tables must be created:

  1. Regular table - stores the current state of the data.
  2. History table - named <regular_table_name>_history, it retains past versions of the data.

The history table must have the same schema as the regular table plus a timestamp column. The timestamp should be part of the primary key to ensure proper versioning of stored data.

This ensures that historical records are safely stored and accessible without overwriting previous states.

Note:

After MyDbProvider is ready, don't forget to register it:

    // Core registration
    Services.AddSingleton<MyDbProvider>();

    // ====== RESILIENT SERVICE REGISTRATION =======
    services.AddSingleton<IConnectable>(sp => sp.GetRequiredService<MyDbProvider>());
    // ====== RESILIENT SERVICE REGISTRATION =======

    // General database provider registration for Altruist
    services.AddSingleton<IGeneralDatabaseProvider>(
        sp => sp.GetRequiredService<MyDbProvider>()
    );

Here's why each registration matters:

  • MyDbProvider: Registers the concrete type so it can be injected directly into modules or features that are tightly coupled to it.
  • IGeneralDatabaseProvider: Used by the Altruist framework to dynamically select and handle different database providers based on context.
  • IConnectable: Declares this service as a Resilient Service, allowing Altruist to manage connection lifecycle, monitor health, and gracefully react to downtime.

Note:

💡 For more info on making services resilient, check out the Resilient Services section.

Step 2 (Optional): Implementing the ILinqVault (MyDB.cs)

The framework provides a Linq implementation so it is only necessary if you are not satisfied with it, otherwise skip this part.


You’ll need to implement a vault that defines the storage and retrieval of your data models. The ILinqVault interface extends from IVault<TVaultModel>, you can implement your custom logic using this.

public class MyLinqVault<TVaultModel> : ILinqVault<TVaultModel> where TVaultModel : class, IVaultModel {
    // implement
}

These methods include:

  • Where: Filters your models based on a predicate.
  • OrderBy: and OrderByDescending: Sorts the models.
  • Take: Limits the number of results returned.
  • SaveAsync: Saves a single model to the database.
  • UpdateAsync: Updates a model in the database.

Note:

Again, most of the cases these methods will act just like a wrapper by using the EF Core's DBContext parameter provided by the base class.


For save and update methods, you need to be very careful if you wish historical saves.

  • If an entity is marked with StoreHistory=true, you must manually handle historical saves into the previously created historical table to ensure past versions are retained.
  • If StoreHistory=false, you can proceed with regular save/update logic without keeping historical records.

This is essential to stay within the framework’s design and ensure proper data integrity when extending Altruist.

If you do implement a new vault provider, you will also need to override the VaultFactory functionalities to be able to construct your vault:

public class MyVaultFactory : VaultFactory
{
    private readonly IGeneralDatabaseProvider _databaseProvider;

    public IDatabaseServiceToken Token => _databaseProvider.Token;

    public MyVaultFactory(IGeneralDatabaseProvider databaseProvider)
    {
        _databaseProvider = databaseProvider;
    }

    public virtual IVault<TVaultModel> Make<TVaultModel>(IKeyspace keyspace) where TVaultModel : class, IVaultModel
    {
        if (_databaseProvider is ILinqDatabaseProvider linqDatabaseProvider)
        {
            // constructing your vault
            return new MyLinqVault<TVaultModel>(linqDatabaseProvider, keyspace);
        } else 
        {
            // otherwise fall back to original
            return base.Make<TVaultModel>();
        }
    }
}

Note:

Replace the default factory using:

services.AddSingleton<MyVaultFactory>();
// replaces the Altruist instance
services.AddSingleton<IVaultFactory>(sp => sp.GetRequiredService<MyVaultFactory>());

Don't forget that you can always find your factory using:

var myVault = provider.GetServices<IVaultFactory>().Where(f => f.Token == MyDBToken.Instance)

The factory pattern is necessary as with Altruist you should be able to connect multiple database providers in one application to support multiple needs. This ensures that the system can safely handle and choose between them.

Step 3: Configuration Setup (Strategy.cs)

You need to create a configuration class that defines the database-specific configuration. For example, if you’re implementing a custom provider, you might define it as follows:

// holds any kind of DB configuration, add/load properties freely
public sealed class MyDBConfiguration : IDatabaseConfiguration
{
    public string DatabaseName => "MyDB";

    public void Configure(IServiceCollection services)
    {
        // Add your non-dynamic database-specific service setup here.
        // This does not include setting up the connection
        services.AddSingleton(this);
    }
}

This will allow you to configure your database provider within the Altruist framework, by registering the necessary services requied by the custom provider.

Step 4: Creating a Token for Your Provider (Strategy.cs)

Next, define a token class for your custom database provider:

public sealed class MyDBToken : IDatabaseServiceToken
{
    public static MyDBToken Instance { get; } = new MyDBToken();
    public MyDBConfiguration Configuration => new MyDBConfiguration();

    public string Description => "💾 Database: MyDB";
}

This token is necessary for Altruist to know which database provider is being used.

Note:

With this pattern, you will be able to access your token and configuration any time like:

MyDBToken.Instance.Configuration

Step 5: Database Connection Setup (Strategy.cs)

Create a connection setup class for your database provider. This is a perfect place for setting up the provider, connection and other dynamic dependencies.

But first, create a keyspace:

To create a keyspace simply extend IKeyspace and give a custom name to it. A keyspace is a logical namespace that organizes your database tables. You can add vaults to a keyspace using: AddVault<T>.

public abstract class MyDefaultKeyspace : IKeyspace
{
    public IDatabaseServiceToken DatabaseToken = MyDBToken.Instance;
    public string Name { get; set; } = "altruist";
    // Don't let this bother you, ignore for now
    public ReplicationOptions? Options { get; set; } = new ReplicationOptions();
}

Create your keyspace setup:

This allows automatically creating databases/tables using the models in the system (whichever vault is added using AddVault). Not necessary, if you are leveraging EF Core functionalities just leave the Build() empty.

public class MyKeyspaceSetup<TKeyspace> : KeyspaceSetup<TKeyspace> {

    // An Example Implementation to Call CreateTableAsync
    public override void Build()
    {
        var provider = Services
                .BuildServiceProvider()
                .GetService<IMyDbProvider>();

        if (provider == null)
        {
            throw new InvalidOperationException("MyDB provider is not registered.");
        }

        provider.CreateKeySpaceAsync(Instance.Name, Instance.Options);
        foreach (var vault in VaultModels)
        {
            provider.CreateTableAsync(vault, Instance);
        }
    }
}

Now create the DB Connection Setup

If you prefer to rely entirely on EF Core without handling these details manually, simply leave the implementation empty. This way, you avoid dealing with unnecessary complexities.

However, if you decide to leave them empty, at least log a cool message—let’s keep things informative and fun! 😉

public sealed class MyDBConnectionSetup : DatabaseConnectionSetup<MyDBConnectionSetup>
{
    public MyDBConnectionSetup(IServiceCollection services) : base(services)
    {
    }

    // Implement this, below is an example implementation
    public override MyDBConnectionSetup CreateKeyspace<TKeyspace>(
    Func<KeyspaceSetup<TKeyspace>, KeyspaceSetup<TKeyspace>>? setupAction = null) {
        var keyspaceInstance = new TKeyspace();
        var keyspaceName = keyspaceInstance!.Name;

       if (!Keyspaces.TryGetValue(keyspaceName, out var keyspaceSetup))
        {
            keyspaceSetup = new MyKeyspaceSetup<TKeyspace>(_services, keyspaceInstance);
            Keyspaces[keyspaceName] = keyspaceSetup;
        }

        setupAction?.Invoke((KeyspaceSetup<TKeyspace>)keyspaceSetup);
        return this;
    }

    // Example Build Implementation
    public override void Build(IAltruistContext settings)
    {
        // use MyDBToken.Instance.Configuration as needed
        ILoggerFactory factory = _services.BuildServiceProvider().GetRequiredService<ILoggerFactory>();
        ILogger logger = factory.CreateLogger<MyDBConnectionSetup>();

        _services.AddSingleton<IMyDbProvider>(sp => new MyDbProvider(_contactPoints));
        _services.AddSingleton(sp => new VaultFactory(sp.GetRequiredService<IMyDbProvider>()));

        // Example building keyspaces
        if (Keyspaces.Count == 0)
        {
            new MyKeyspaceSetup<MyDefaultKeyspace>(_services, new MyDefaultKeyspace()).BuildInternal();
        }
        else
        {
            foreach (var keyspaceSetup in Keyspaces.Values)
            {
                keyspaceSetup.BuildInternal();
            }
        }

        logger.LogInformation("⚡ MyDB support activated.");
    }
}

Step 6: Creating a Vault Repository (MyDB.cs)

Create a vault repository for your custom database provider:

public class MyVaultRepository<TKeyspace> : VaultRepository<TKeyspace> where TKeyspace : class, IMyKeyspace
{
    public MyVaultRepository(IServiceProvider provider, IGeneralDatabaseProvider databaseProvider, TCustomKeyspace keyspace) 
        : base(provider, databaseProvider, keyspace)
    {
    }
}

This repository class allows you to manage your vault models and interact with the database.

Note:

Register it using:

services.AddSingleton<IVaultRepository<TKeyspace>>(sp =>
{
    var provider = sp.GetRequiredService<IMyDBProvider>();
    return new MyVaultRepository<TKeyspace>(sp, provider, instance);
});

Step 7: Extension for Altruist Builder (Extension.cs)

Finally, you need to create an extension for the Altruist application builder to integrate your custom provider:

public static class Extension
{
    public static AltruistApplicationBuilder WithMyDB(this AltruistDatabaseBuilder builder, Func<MyDBConnectionSetup, MyDBConnectionSetup>? setup = null)
    {
        return builder.SetupDatabase(MyDBToken.Instance, setup);
    }
}

This extension method adds your custom database provider to the Altruist application, allowing you to easily configure it.

Result

As a result you should be able to easily setup altruist with your database provider

AltruistBuilder.Create(args)
    .NoEngine()
    .WithWebsocket(setup => setup.MapPortal<SimpleGamePortal>("/game"))
    .WithRedis(setup => setup.AddDocument<Spaceship>())
    // Use your extension here
    .WithMyDB(setup => setup.AddVault<MyFistVault>())
    // Your Extension End
    .WebApp()
    .StartServer();

Interacting With The Database

To interact with your database use either your provider directly or through VaultRepositoryFactory. Factory is recommended when the system connects to multiple databases.

Direct Usage:

public class MyService {
    public MyService(MyDbVaultRepository<MyDefaultKeyspace> repository) {
        // Player must be added as Vault to MyDefaultKeyspace
        var playerTable = repository.Select<Player>();
        // use LINQ-like queries on playerTable
    }
}

Through Factory Pattern

public class MyService {
    public MyService(VaultRepositoryFactory vaultFactory) {
        var repository = vaultFactory.Make<MyDefaultKeyspace>();
        // Player must be added as Vault to MyDefaultKeyspace
        var playerTable = repository.Select<Player>(); 
        // use LINQ-like queries on playerTable
    }
}

Conclusion

By following these steps, you can implement a custom database provider for the Altruist framework. Whether you're using SQL-based databases or other NoSQL options, this guide will help you integrate your own provider seamlessly into the framework.

If you're looking for support with other types of databases, you can extend this process to create a provider for your database of choice. Once you’ve done that, your custom provider will be ready to use within the Altruist ecosystem!