A .NET client for building applications on top of GNU Health (the Tryton-based HMIS/EMR).
GnuHealth.Net talks to GNU Health through the trytond JSON-RPC API — the same interface the official desktop and web clients use — rather than reaching into the PostgreSQL database directly. Every read and write runs through the real ORM, so business logic, validation, code sequences, access control and workflow transitions are enforced server-side. Your integrations get a clean, typed API and cannot leave the database in a state the application can't handle.
- 🔒 Safe by construction — no hand-reimplemented ORM logic, no schema corruption.
- ⌨️ Strongly typed — 498 POCOs covering the entire model catalog, generated from a live server.
- 🧩 Untyped escape hatch — work with any model/field dynamically when you need to.
- 🧪 Testable & DI-friendly — interface seams, a fakeable transport,
IServiceCollectionwiring. - 🎯 Broad reach —
netstandard2.0core (usable from .NET Framework 4.6.1+, .NET 5–8, MAUI, Unity).
- Why JSON-RPC and not SQL?
- Architecture
- Getting started
- Usage
- Core concepts
- Model coverage & code generation
- Build, test, pack
- Good to know (GNU Health specifics)
- Security
- Roadmap
You can write to GNU Health's PostgreSQL database directly — but to do it safely you would have to re-implement, per model and per version, everything the ORM does:
- code sequences (
prescription_id, evaluation/lab codes, party PUIDs, …), create/writeoverrides andvalidate()rules,- workflow state machines (surgery
draft→confirmed→done→signed, …), - computed/function fields,
ir_model_data, history tables,ir.property, - access rules, record rules and translations.
Going through the JSON-RPC API means all of that is handled for you by trytond. The trade-off is that bulk reads are a little slower than raw SQL — so for heavy reporting/BI, read-only SQL is still fine; just keep all writes on the API.
Clean, inward-pointing dependencies (the domain contracts have no dependencies of their own):
GnuHealth.Abstractions
(interfaces, value types, attributes, Domain, WriteCommand, exceptions)
▲ ▲
│ │
GnuHealth.Client GnuHealth.Models GnuHealth.Codegen
transport · serialization generated POCOs introspects a live server,
ORM · mapping · sessions (by module ns) emits GnuHealth.Models
configuration · DI
| Project | Target | Responsibility |
|---|---|---|
GnuHealth.Abstractions |
netstandard2.0 | Contracts and value types: IGnuHealthConnection, IModelClient, ITypedModelClient<T>, IRecordMaterializer, ITrytonRpcClient; TrytonDate, TrytonRecord, Domain, WriteCommand, [TrytonModel]/[TrytonField] |
GnuHealth.Client |
netstandard2.0 | Implementation, split by concern: Rpc/ (JSON-RPC transport), Serialization/ (Tryton __class__ value codec), Orm/ (ModelClient, TypedModelClient), Mapping/ (reflection materializer), Sessions/, Configuration/, DependencyInjection/ |
GnuHealth.Models |
netstandard2.0 | 498 generated POCOs grouped into GnuHealth.Models.<Module> namespaces |
GnuHealth.Codegen |
net8.0 | CLI that reads fields_get from a live server and emits the POCOs |
samples/GnuHealth.Sample |
net8.0 | Runnable example (env-based credentials) |
tests/GnuHealth.Client.Tests |
net8.0 | xUnit unit tests + env-gated live integration tests |
The layering follows SOLID: one responsibility per type, dependencies expressed as interfaces (DIP),
and swappable seams (ITrytonRpcClient, IRecordMaterializer) — which is how the ORM layer is
unit-tested without a live server.
Prerequisites
- A reachable GNU Health / Tryton server with the JSON-RPC service enabled (default port
8000). - A Tryton login — ideally a dedicated, scoped service account rather than
admin. - .NET SDK 8.0+ to build.
Add the libraries. They aren't on nuget.org yet; reference the projects (or the .nupkgs built by
dotnet pack, see below):
<ItemGroup>
<ProjectReference Include="path/to/GnuHealth.Client/GnuHealth.Client.csproj" />
<ProjectReference Include="path/to/GnuHealth.Models/GnuHealth.Models.csproj" />
</ItemGroup>Provide credentials via environment (never hard-code them):
GNUHEALTH_URL=http://your-host:8000
GNUHEALTH_DB=your-database
GNUHEALTH_USER=api_svc
GNUHEALTH_PASSWORD=••••••••
using GnuHealth.Client;
using GnuHealth.Client.Configuration;
var o = GnuHealthOptions.FromEnvironment(); // or set BaseUrl/Database/Login/Password directly
using var conn = new GnuHealthConnection(o.BaseUrl, o.Database);
Console.WriteLine(await conn.ServerVersionAsync()); // e.g. "7.0.45"
await conn.LoginAsync(o.Login, o.Password); // opens a session; loads user contextusing GnuHealth.Abstractions;
using GnuHealth.Models.Health; // module namespaces: .Health .Party .Product .Account .Stock .Ir ...
var patients = conn.Model<Patient>();
// read one (specify fields to keep it light; defaults to all mapped fields)
Patient? p = await patients.GetAsync(5, new[] { "rec_name", "blood_type", "rh" });
// search -> POCOs
IReadOnlyList<Patient> group = await patients.SearchAsync(
Domain.Where("blood_type", "=", "O"),
limit: 20,
order: new object[] { new object[] { "id", "ASC" } },
fields: new[] { "rec_name", "gender", "blood_type" });Any of the 500 models, no POCO required — values come back as CLR types
(long, string, bool, decimal, TrytonDate, DateTime, byte[], nested records):
var lab = conn.Model("gnuhealth.lab");
long[] ids = await lab.SearchAsync(Domain.Where("patient", "=", 5L), limit: 10);
IReadOnlyList<TrytonRecord> rows = await lab.ReadAsync(ids, new[] { "name", "test.rec_name", "state" });
foreach (var r in rows)
Console.WriteLine($"{r.GetString("name")} {r.GetRelated("test")?["rec_name"]} {r.GetString("state")}");Creates run every server-side default, validation and sequence:
long labId = await conn.Model("gnuhealth.lab").CreateOneAsync(new Dictionary<string, object?>
{
["test"] = 25L, // many2one -> id
["patient"] = 5L,
["date_requested"] = DateTime.UtcNow, // encoded as a Tryton datetime
["state"] = "draft",
});
await conn.Model("gnuhealth.lab").WriteAsync(new[] { labId }, new Dictionary<string, object?>
{
["diagnosis"] = "Reviewed",
});
// typed create/update round-trips the POCO's writable fields:
var disease = new PatientDisease { Patient = 5, Pathology = 2077, Status = "c", DiagnosedDate = new TrytonDate(2026, 1, 15) };
long id = await conn.Model<PatientDisease>().CreateAsync(disease); // disease.Id is stamped
disease.Status = "h";
await conn.Model<PatientDisease>().UpdateAsync(disease, new[] { "status" });Use WriteCommand to build the command tuples trytond expects:
await conn.Model("gnuhealth.lab").WriteAsync(new[] { labId }, new Dictionary<string, object?>
{
["critearea"] = new object[]
{
WriteCommand.Create(new Dictionary<string, object?> { ["name"] = "Glucose", ["result"] = 92m }),
WriteCommand.Add(5, 6), // link existing rows
WriteCommand.Remove(9), // unlink (does not delete)
},
});await conn.Model("gnuhealth.surgery").ButtonAsync("confirmed", new[] { surgeryId });using GnuHealth.Client.DependencyInjection;
services.AddGnuHealthClient(o =>
{
o.BaseUrl = cfg["GNUHEALTH_URL"]!;
o.Database = cfg["GNUHEALTH_DB"]!;
o.Login = cfg["GNUHEALTH_USER"]!;
o.Password = cfg["GNUHEALTH_PASSWORD"]!;
});
// later:
var factory = provider.GetRequiredService<IGnuHealthConnectionFactory>();
using IGnuHealthConnection conn = await factory.ConnectAsync(); // creates + logs in// Resolve many2one relations to POCOs (single or batched, avoiding N+1):
var doctor = await conn.LoadAsync<Healthprofessional>(patient.PrimaryCareDoctor);
var doctors = await conn.LoadManyAsync<Healthprofessional>(patients.Select(p => p.PrimaryCareDoctor));
// Page through every match:
var all = await conn.Model<Patient>().SearchAllAsync(Domain.Where("blood_type", "=", "O"), pageSize: 500);
// Run a report -> bytes:
ReportResult label = await conn.ExecuteReportAsync("party.label", new long[] { partyId });
File.WriteAllBytes($"{label.Name}.{label.Type}", label.Content);
// Drive a wizard (low-level, stateful):
var wiz = await conn.CreateWizardAsync("gnuhealth.lab.test.create");
await wiz.ExecuteAsync(wiz.StartState, data);
await wiz.DeleteAsync();Domain— a search filter.Domain.All,Domain.Where("f","=",v).And("g",">",1), or combine:Domain.Combine("OR", Domain.Where(...), Domain.Where(...)).WriteCommand—Create/Write/Add/Remove/Deletetuples for relational fields.TrytonDate— a date without time, for Trytondatefields (DateTimemaps todatetime).TrytonRecord— an untyped row with typed accessors (GetString,GetId,GetDate,GetRelatedfor dotted reads like"test.rec_name").- Context — the user's language/company context is loaded at login and merged into every call;
pass a per-call
contextdictionary to override. - Value marshalling — trytond encodes
date/datetime/Decimal/bytes/timedeltaas tagged objects; the client converts them to/from native CLR types transparently. - Typed errors — failures surface as
TrytonUserError(validation/business rules, safe to show users),TrytonUserWarning,TrytonConcurrencyException,TrytonAuthenticationExceptionandTrytonTransportException— all deriving fromTrytonRpcException. - Resilient sessions — an expired session (HTTP 401) is transparently re-authenticated and the
call retried once. Transient transport failures (network / 502 / 503 / 504 / 429) are retried with
exponential backoff (
GnuHealthOptions.MaxRetries). Via DI, connections use a pooledHttpClient(IHttpClientFactory), so creating many connections won't exhaust sockets. Pass anILogger(or anILoggerFactoryvia DI) to log calls, retries and re-auth. - Selection enums — Tryton
selectionfields with a static option list are generated as typed enums (e.g.Patient.BloodTypeKind.O,Patient.GenderKind.Female) mapped to their wire values via[TrytonValue]. (Symbol-only options such as Rh+/-get generic member names — the value is still preserved on the attribute.)
GnuHealth.Models is generated from a live server, so it always matches that instance's version and
customizations. This build covers 498 models / ~7,000 fields, grouped by module:
| Namespace | Models | Namespace | Models | |
|---|---|---|---|---|
GnuHealth.Models.Health |
201 | GnuHealth.Models.Stock |
40 | |
GnuHealth.Models.Account |
94 | GnuHealth.Models.Product |
22 | |
GnuHealth.Models.Ir |
82 | GnuHealth.Models.Party |
20 | |
GnuHealth.Models.Calendar |
13 | GnuHealth.Models.Res |
11 | |
| Country · Currency · Company · Webdav | 15 |
Regenerate against your own server — either from source, or install the published tool:
# as a global dotnet tool:
dotnet tool install -g GnuHealth.Codegen
gnuhealth-codegen http://host:8000 db admin PASSWORD --all --out ./Generated
# or from source (whole catalog; run as admin so admin-only models introspect fully):
dotnet run --project src/GnuHealth.Codegen -- http://host:8000 db admin PASSWORD \
--all --out src/GnuHealth.Models/Generated
# or a subset:
dotnet run --project src/GnuHealth.Codegen -- http://host:8000 db api_svc PASSWORD \
--models gnuhealth.patient,gnuhealth.lab --out src/GnuHealth.Models/GeneratedGenerating a POCO doesn't grant access — a record is still only readable/writable if the connected user's groups permit it.
dotnet build
# Unit tests always run. Live integration tests run only when GNUHEALTH_* env vars are set:
dotnet test
# Produce NuGet packages into ./artifacts:
dotnet pack -c Release -o artifactscommon.server.versionis served at the server root and is unauthenticated; the client routes it there automatically (all model calls go to the per-database endpoint).- Functional fields may not be searchable. For example
gnuhealth.patient.genderis computed and has no searcher — you can read it but not filter on it; filter on stored fields (e.g.blood_type) or the underlyingparty.party.gender. - Empty many2one comes back as
false/null; typed properties are nullable and map it tonull.
- Use a dedicated, scoped service account (not
admin). Grant it only the groups your product needs; audit trails then attribute changes to that account. - Keep credentials in environment variables / user-secrets / a secret store — never in source.
Two independent release streams, driven by tags (see .github/workflows/ci.yml):
| Tag | Publishes | Versioning |
|---|---|---|
v1.2.3 |
GnuHealth.Abstractions, GnuHealth.Client, GnuHealth.Codegen |
the client libraries' own semver |
models-v5.0.1 |
GnuHealth.Models |
tracks the GNU Health release it was generated from |
So consumers can pin GnuHealth.Models to their server's GNU Health version, e.g.
<PackageReference Include="GnuHealth.Models" Version="5.0.*" />.
Publishing uses NuGet Trusted Publishing (OIDC) — no stored API key. One-time setup:
- nuget.org → your account → Trusted Publishing → add a policy: repository owner + repo,
workflow file
ci.yml(filename only). - Add a repo secret
NUGET_USER= your nuget.org account/profile name.
The workflow requests id-token: write, runs NuGet/login@v1 to mint a 1-hour key, and pushes.
GnuHealth.Models is generated from a stock GNU Health install, so it's an accurate baseline for
standard deployments. If your server runs extra/fewer modules or has custom fields, regenerate with the
gnuhealth-codegen tool to match it exactly — an unused generated POCO is harmless, but custom fields
won't be present until you regenerate. Cut a new models-v* release whenever GNU Health releases a new
version (regenerate against a stock server of that version, commit, tag).
- Change-tracking for partial typed updates;
IAsyncEnumerablestreaming for large reads. - Richer enum member naming for symbol-only selections; typed wizard state models.
- A published NuGet feed (CI already builds/tests/packs and publishes on tags).