(updated)
|
8
min. read

What I learnt building a .NET SDK

Christiaan Landman

Problem Statement

When I was asked to develop a .NET SDK for PowerSync, I knew I was stepping outside my comfort zone. I’d never built a PowerSync SDK from scratch - and my only C# experience came from some casual Unity side projects. Suddenly, I was navigating one of our most requested roadmap items, meant to support MAUI, Blazor, WPF, and others - right as developers were looking for alternatives to Realm-Dotnet following the Atlas deprecation announcement.

Despite my time working on our Dart and JS SDKs, this was uncharted territory: unfamiliar tooling, deeper parts of the PowerSync architecture, and a language I barely knew beyond the basics. Here’s how I approached it.

The Learning Approach

Learning and mastering new programming languages evolves throughout your career:

  • Learning your first language usually demands significant effort, as you're simultaneously grasping fundamental programming concepts.
  • Learning your second language becomes more intriguing, as you leverage your existing knowledge, often experiencing moments like: "Oh, that's just a different syntax than I'm used to" or, "This feature would've been handy in the previous language!".
  • When you've stopped counting how many languages you know, ideally, you've established a structured learning approach, leaving behind the days of blindly stumbling through unfamiliar territory.

These days, my approach is to go directly to a language's official documentation. Microsoft's resources for both C# and .NET are exceptional, catering effectively to developers of all skill levels.

Once I've built some initial comfort with a language, I typically reinforce my knowledge by working through the interactive exercises on Exercism. Their guided modules cover language specific concepts comprehensively within an online IDE - I highly recommend this platform.

The Plan

With this learning approach as my foundation, I devised a strategy blending research, targeted learning, and hands-on experimentation to tackle the PowerSync .NET SDK project:

  • Understand how the existing stable SDKs (powersync.dart and powersync-js) currently operate
  • Determine precisely where surface-level interfaces end and meaningful functionality begins
  • Map out the conceptual areas of the SDK architecture
  • Apply my structured language learning process to C# and .NET
  • Work toward an MVP by identifying and addressing core challenges

This approach would allow me to simultaneously develop my C# skills while building a foundational understanding of what makes PowerSync SDKs work under the hood.

How SDKs Cross Boundaries

When building an SDK, understanding the interfaces is critical. For PowerSync, these interfaces include:

  • Queries: How the SDK interfaces with the local SQLite database
  • Data synchronization: How data is fetched and synchronized from the PowerSync Service
  • Uploading local changes: How local data modifications are processed and uploaded via a provided connector

I began by reviewing the powersync-js reference SDK. Rather than attempting to memorize JavaScript implementation details, I focused on mapping the core conceptual architecture.

This was the general picture I had built up in my head.

With this understanding in place, I could confidently outline the initial structure for the .NET PowerSync.Common package.

Working Towards an MVP

When developing SDKs from scratch, you want to identify the core challenges early - particularly those unique to the target language and environment. For PowerSync in the .NET ecosystem, I needed to consider:

Target Frameworks

  • WPF and .NET for desktop applications.
  • MAUI for cross-platform desktop and mobile apps.
  • Blazor for web-based scenarios.
  • Special mentions:
    • Unity (yes, the game engine!) for gaming and interactive experiences.
    • AvaloniaUI for cross-platform desktop applications.

Initially, the goal is to ensure that our Common SDK runs smoothly across as many target frameworks as possible. When dedicated runtime support or reactive bindings become necessary, we can introduce these as supplemental packages. For example in our JS package suite, we have a @powersync/common package but @powersync/react, @powersync/react-native , and @powersync/web  contain platform-specific functionality provided for full compatibility.

API Ergonomics and Language Conventions

A important lesson in building our .NET SDK was realizing that idiomatic design matters more than just porting functionality from JavaScript or Dart. What feels natural in one language can feel awkward in another.

We’re actively incorporating feedback from developers and reviewing open source libraries to guide these decisions. Even small choices - like preferring builders over inline dictionaries, or using Dispose instead of CancellationTokenSource - can make the API feel more intuitive and familiar.

Database Adapter Implementation

This should then wrap a SQLite driver/package that works for your target platforms. In some scenarios, you may even need multiple adapters to adequately cover the full spectrum, from desktop and mobile to web.

To date, Microsoft.Data.Sqlite has proven robust, providing reliable support and clear examples across .NET, WPF, and MAUI platforms. Most of the work left for support platforms like MAUI would be in ensuring that the PowerSync extension is added correctly (this work is done incrementally as we add official support for more frameworks).

SQLite Extension Integration

Our SQLite Core Extension provides shared SQLite functionality that is used consistently across all our SDKs. Since it does a lot of the heavy lifting, it is important to be able to load this extension within each target environment. During initial development on macOS, loading the SQLite extension turned out to be straightforward - I was able to integrate and validate it quickly within a unit test.

Schema Definition

With the SQLite extension in place, it was possible to translate schema definitions into a structured format, passing them to the extension. This enabled automatic setup of the tables and views required to power PowerSync’s core functionality.

Database Hooks and Watched Queries

In our Dart and JS SDKs, we rely on database hooks to enable two features:

  1. Local Change Detection – Used to determine when a local CRUD operation (insert/update/delete) has occurred that needs to be uploaded to the backend.
  2. Watched Queries – Notifies watched queries when tables involved in their query have changed, prompting automatic re-execution and UI updates.

Fortunately, we also have access to low-level database hooks when using Microsoft.Data.Sqlite via SQLitePCLRaw:

raw.sqlite3_rollback_hook(Db.Handle, RollbackHook, IntPtr.Zero); raw.sqlite3_update_hook(Db.Handle, UpdateHook, IntPtr.Zero);

Our typical watched query implementation typical involves a few simple steps:

  1. Execute the query for the initial result.
  2. Use EXPLAIN to resolve which tables the query depends on.
  3. Subscribe to the DBAdapter for any updates on tables.
  4. When notified of a change, check for an intersection between the modified tables and the dependent tables.
  5. If an intersection exists, re-execute the query.

Implementing Data Synchronization

To be able to connect to a backend and the PowerSync Service, we required a BackendConnector implementation. Initially I started with porting the Demo Node connector, but by the time of writing we also had a port of the Supabase Connector using the Supabase C# SDK.

With the connector in place, we could now implement an HTTP stream to consume data synchronized down from the PowerSync Service. Additionally, we could process local data changes, passing them through the connector for upload to our self-hosted Node backend.

Releasing

When the package is finally ready to be shared with the first batch of alpha testers, it’s important to have a basic release process in place. For .NET libraries, the go-to platform for distribution is NuGet (pronounced “New-get”, and not “Nugget” 🍗). Publishing on this platform allows developers to easily consume your SDK using familiar tools like dotnet add or via Visual Studio’s built-in UI.

Testing and Demonstration

Throughout development, I defined tests for each component as it was implemented. Where possible, I ported existing tests from our Dart and JS SDKs to validate equivalent behavior.

To demonstrate the SDK's functionality, I created a simple CLI-based demo that syncs todo lists in real-time. This served both as a proof of concept and as a reference implementation for developers looking to integrate PowerSync into their own .NET projects.

A React web demo syncing data with the newly built .NET CLI demo.

The Role of LLMs in My Learning Process

Large Language Models proved invaluable during this project - particularly for translating concepts I understood in other languages into their C# equivalents. They helped bridge the gap between familiarity and fluency, generating quick prototypes and scaffolding test cases when I needed to move quickly.

However, I followed one important rule:

    Use LLMs to write either the code or the tests - but never both.

This approach ensures I truly understand at least one side of the implementation, maintaining code quality and forcing deeper comprehension of the system.

TL;DR

  • Start by mapping concepts, not code.
  • Learn the target language idioms deeply - read open source, ask devs.
  • Use LLMs for acceleration, but don’t outsource your understanding.
  • Keep the SDK modular and framework-agnostic where possible.

Thanks for reading - I hope this writeup helps anyone venturing into unfamiliar language ecosystems or building SDKs from the ground up.