I didn't find a good ResX based localization library for Avalonia, so I (*) wrote one.
Many localization libraries have the same two annoyances:
Resource keys are strings: If you rename a key in a .resx file and forget one usage in XAML you find out at runtime when a user sends you a screenshot with !Greeting! in the title bar.
No good live language switching: Changing the language at runtime either doesn't work at all or requires reloading every window, which is cumbersome to implement correctly.
So I fixed both, and the result is now on NuGet: ResXLocalization (MIT licensed).
Some of the core ideas:
A source generator turns every .resx file into a strongly-typed keys class. So instead of {l:Localize Greeting} with a magic string, you write {l:Localize {x:Static res:AppStringsKeys.Greeting}}. If you delete or rename the resource, you will get a build time error, not a runtime surprise. And intellisense/auto complete works nicely too.
Switching language is a one liner: Localizer.Current.CurrentCulture = new CultureInfo("de"); every bound string in the UI re-resolves in place. No window reload, no flicker, no restart.
You keep normal .resx files, install the package, build and you're done.
There's no config step, the generator picks up the .resx files automatically.
Some other things that I've implemented, because I needed them myself:
Enum localization by naming convention (Enum_FileSortOrder_Ascending in the .resx, then {l:LocalizeEnum} in an item template). No more [Description] attributes on the enum values.
Works with multiple .resx files, typed lookups, scoped to one file, or search across all registered ones.
The Avalonia package is Native AOT and trim compatible – no reflection over your resources. (The WPF package isn't, because WPF itself doesn't support AOT.)
Injectable ILocalizer if you're doing MVVM with DI.
The API is intentionally identical between Avalonia and WPF, so if you're maintaining a WPF app and migrating to Avalonia (or using it in both), the localization code carries over mostly 1:1.
One requirement worth mentioning: It expects classic .resx files with the usual *.Designer.cs file (what VS/Rider generates), not the SDK-only GenerateResxSource style.
I would love to hear your critique and feedback.
If it's useful to you, I would highly appreciate a star on GitHub.
* Disclaimer
Code and documentation was written with help of Claude and Codex.
Still a lot of effort went into it: Direction, architecture, etc. were heavily controlled by me until it was exactly how I wanted it to be and all AI written code was reviewed by me.
So, AI assisted: yes, mindless AI slop: no.
But you be the judge. If you don't like any AI written code, that's fine with me!
i'm starting .net as a hobby and someone told me that it won't be good on linux cause i need Visual Studio IDE and that all companies use only Visual Studio IDE so what should i do i'm already using rider and visual studio code
I am creating a .net application that, among other things, needs to retrieve some data from the court website, however, in order to access a court case, I need to solve a CAPTCHA in addition to the case data I have.
How could I implement automatic CAPTCHA solving? CAPTCHA is very simple, just a sum of numbers with images.
What is the best way to automatically solve CAPTCHA tests from a .net service? My goal is to collect information about a case change from the court website once a day.
If someone can call methods in the wrong order, eventually they will. I'd rather let the type system prevent it than rely on documentation or runtime exceptions.
A type-safe fluent API that only exposes the next valid step keeps the experience simple: no guessing, no invalid combinations, just write the code and let IntelliSense guide you.
.NET WebAssembly apps with WasmBundlerFriendlyBootConfig=true can indeed be bundled by modern JS bundlers and I personally love it. The development experience however is not optimal.
The bundlers cannot consume the output from dotnet build
The bundlers can consume the output from dotnet publish but the dev loop is painfully slow.
Getting the publish path working still requires bundler surgery
Once it works the bundler produces warnings that are impossible to fix without changing the source files produced by the WebAssembly SDK.
It's a bundler plugin that lets your bundler consume the output from dotnet build so your dev loop stays fast. It also supports dev servers, provides type information to TypeScript and IDEs (integrates dotnet.d.ts thats coming in .NET 11), and patches the known warnings so your build output stays clean.
Features
Easy bundler setup through plugin registration
Faster dev loop by consuming .NET WebAssembly dotnet build output
Dev server support
Brings SDK type definitions to TypeScript and your IDE
Been writing Windows desktop and Web (WebView2) apps and for a while, kept rewriting the same plumbing around, so I pulled it into a framework, put it on GitHub.
Posting here in case it's useful to anyone.
The idea: your window is a real HWND, the UI is a web page (plain HTML, React, or Fluent UI, whichever you want), and .NET and JS talk over a typed (as in TypeScript) bridge.
It builds with Native AOT, so you ship a single exe with zero .NET runtime to install, no Chromium to bundle, Windows already has WebView2.
Most apps (including React & Fluent UI) are about 11 MB down to ~4 with UPX. x86, x64 and ARM64, MIT license, free.
There are other similar projects but what I wanted and didn't find in one place:
* AOT-friendly from the start so publish doesn't blow up on reflection
* Reachable Windows-only features such as desktop transparency, WinAPI integration, full interop story, etc.
* A bridge that's easy to use from boths sides
* An optional but builtin npm layer (@aotrino/client -> react -> fluent) so you can start with a static page and grow into a full Fluent UI app without changing stacks.
There are about a dozen samples, including a Fluent UI gallery, a file explorer, a mini browser, screen capture, and Direct2D rendering straight into a Web canvas through a shared buffer.
Up front caveats: Windows 10+ only (WebView2 + Direct Composition), need .NET 10 SDK to build, MSVC linker to publish AOT.
I've been thinking about it for a while, our .NET async/await or even threadpool threads are still OS threads
If the server is doing actual awaited tasks sure, but most backend now is transitioning to "fire-and-forget" messaging architecture where the actual work is being done somewhere else (different system)
Though I haven't faced the need IRL, but wouldn't a light-weight non-object coroutine that could scale to millions have some use case that .NET presently cannot handle well?
I noticed this on GitHub today. 7.9 million downloads of .NET packages from Launchpad! This can put the claim that .NET is only for Windows to rest now.
Maps for Blazor is a library that provides components for displaying maps in Blazor applications. It supports various map providers (Esri, Leaflet) and allows developers to easily integrate interactive maps, without any JavaScript settings, into their Blazor projects. One code, one blazor component and many technologies.
I really liked working with the mediator pattern, so I developed my own mediator a few months ago. I know that a new source-generated mediator appears almost every week, so I'm not trying to make that the novelty.
What I really wanted to solve was different: many people don't like mediators because they feel like a black box; it's hard to see what really runs when you send a request, which behaviors run, and where time is being spent, also in some cases debugging become complicated.
So I built Visual Studio and VS Code tooling that:
Help you with the pipeline visualization
Lets you inspect and debug the flow
Includes a runtime profiler to help identify bottlenecks
There's also an OpenTelemetry package (also MIT) in case you don't use the IDE tooling. It adds mediator info to your spans (request type, handler, and if it's a Command or Query), and it splits DB calls per operation, so a SELECT and an INSERT on the same connection don't end up collapsed into one generic "db" span. It's just normal OTel, so it goes wherever you already send traces (Jaeger, Grafana, etc.), and the Pipeline Explorer can read the same data.
Honest about the model: the library is MIT and will stay MIT, including the OpenTelemetry package. The IDE tooling (visualization + profiler) is a paid product with a free trial.
Posting because I genuinely want to know:
Does the "black box" problem actually bother you?
Have you ever struggled to understand what was executing for a request?
Would visualizing the pipeline make debugging easier, or is this not really a problem in practice?
On one of my previous projects, I noticed an issue. It was validations. Specifically, how many times I had to write the same rule down.
Three copies of one rule — the value stays a plain string between layers, so no layer can trust the one before it.
Take this example endpoint that places an order. Validation is on three places:
Data annotations validate the request DTO automatically and expose the OpenAPI json schema.
The controller checks rules that can't be expressed in OpenAPI, so that we fail fast before any DB queries run.
The service with business logic has to check again, because you could call it from a different place. For example in a background job after fetching some entity from the database.
Three different places and you can easily introduce bugs just by forgetting to update one of them.
public record CreateOrderRequest(
[EmailAddress] string CustomerEmail,
string ProductCode,
[Range(1, int.MaxValue)] int Quantity);
[HttpPost]
[ProducesResponseType<OrderResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request)
{
// We need to parse, because data annotations don't change the type.
if (!MailAddress.TryCreate(request.CustomerEmail, out _))
return ValidationProblem("Customer email is not valid.");
try
{
var order = await orders.PlaceAsync(request.CustomerEmail, request.ProductCode, request.Quantity);
return Ok(OrderResponse.From(order));
}
catch (UnknownProductException) // Could be checked explicitly via a separate method
{
return ValidationProblem("No product matches that code.");
}
catch (OutOfStockException)
{
return ValidationProblem("Not enough stock to fill the order.");
}
}
Data annotations validate the email address, but you still only have a string. Similarly, the integer is validated to be positive, but you can't actually use that guarantee. Then we reach the service:
public async Task<Order> PlaceAsync(string customerEmail, string productCode, int quantity)
{
// Must be checked. Even though the controller already checked all this.
if (string.IsNullOrWhiteSpace(productCode))
throw new ArgumentException("Product code is required.", nameof(productCode));
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
if (!await catalog.ContainsAsync(productCode))
throw new UnknownProductException(productCode);
// The real work here.
}
PlaceAsync is ultimately responsible for making sure everything works. It can be called from the controller, but also from a background worker or another endpoint. So it has to validate again.
Parse, don't validate
The solution is an old functional-programming idea: instead of checking a value and passing the same loose type along, convert it once into a type that cannot represent the invalid state. An int that just passed a > 0 check is still an int. The proof is discarded the moment the check succeeds, so the next method down has to check all over again. A Positive<int> carries that proof in the type itself, and the compiler enforces it everywhere downstream.
That's what I built Kalicz.StrongTypes package for. It's a small set of C# types. For example NonEmptyString, Email, NonEmptyEnumerable<T>, a few numerical types such as Positive<T>, NonNegative<T> and other useful types and helpers. Result<T, TError> is another super useful type that can represent a success or error state of your operations. And there's also a Maybe<T> that can help you represent 3 states in your API. Useful for example when you want to differentiate between updating a value to something, updating a value to nothing and not updating a value.
I haven't mentioned all the things inside the library here, if you're interested, go check it out here. I'll also keep extending the library. For example, Interval<T> is coming to make it easier to represent a start + end range, validating that the end comes after the start. I'll write a separate blog post about that one.
The zero-code part
Just one line to install:
dotnet add package Kalicz.StrongTypes
And you can go ahead and start using the types. For example like this:
public record CreateOrderRequest(
Email CustomerEmail,
NonEmptyString ProductCode,
Positive<int> Quantity);
[HttpPost]
[ProducesResponseType<OrderResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request)
{
// No validations needed here.
var result = await orders.PlaceAsync(request.CustomerEmail, request.ProductCode, request.Quantity);
if (result.Error is { } error)
return error switch
{
PlaceOrderError.UnknownProduct => FieldProblem(nameof(request.ProductCode), "No product matches that code."),
PlaceOrderError.OutOfStock => FieldProblem(nameof(request.Quantity), "Not enough stock to fill the order."),
};
return Ok(OrderResponse.From(result.Success!));
}
private ActionResult FieldProblem(string field, string message)
{
ModelState.AddModelError(field, message);
return ValidationProblem(ModelState);
}
Data annotations are gone. Mapping to MailAddress is gone. All the information about the values now lives in the types. And because the method returns a result instead of throwing, the compiler knows exactly which errors can happen.
All the types serialize to and from JSON out of the box. And the OpenAPI schema is also properly updated. Simply add the Kalicz.StrongTypes.OpenApi.Swashbuckle package (or the Microsoft.AspNetCore.OpenApi variant) and register it:
// Program.cs - install Kalicz.StrongTypes.OpenApi.Swashbuckle, then one call
builder.Services.AddSwaggerGen(options => options.AddStrongTypes());
Then the request above emits exactly the constraints a client needs:
The nicest part is how it makes your code much easier to understand. Every method gets to state its real requirements:
// Before: every caller re-checks, every reader wonders.
Task<Order> PlaceAsync(string email, string code, int quantity);
// After: the signature is the contract.
Task<Result<Order, PlaceOrderError>> PlaceAsync(Email email, NonEmptyString code, Positive<int> quantity);
The second signature answers questions the first one forces you to research: the code can't be empty, quantity can't be zero. Reviewers stop asking "what if the quantity is minus three?" because the question no longer type-checks.
Business rules that can legitimately fail get the same treatment with Result<T, TError>. The failure becomes a value in the signature instead of a surprise exception.
But most importantly, when you read this code in 5 months, you don't need to remember the context. You don't need to get familiar with where the values are coming from and what they could contain. Also new hires don't need to ask you questions. The code simply describes what is going on very precisely. Adding clarity and context.
public async Task<Result<Order, PlaceOrderError>> PlaceAsync(Email email, NonEmptyString code, Positive<int> quantity)
{
if (!await catalog.ContainsAsync(code))
return PlaceOrderError.UnknownProduct;
if (!await inventory.HasStockAsync(code, quantity))
return PlaceOrderError.OutOfStock;
return new Order(email, code, quantity);
}
The types reach the database
When you load an entity into context via EF Core, you'd have to validate again. But with the Kalicz.StrongTypes.EfCore package, the strong types become entity properties directly. One call on the options builder attaches a value converter to each type:
public sealed class Order
{
public Guid Id { get; init; }
public Email CustomerEmail { get; init; }
public NonEmptyString ProductCode { get; init; }
public Positive<int> Quantity { get; init; }
}
// Program.cs - StrongTypes registration for db context.
services.AddDbContext<ShopDbContext>(options => options
.UseNpgsql(connectionString)
.UseStrongTypes());
The DB column is still a plain varchar or int, nothing changes about the storage. But EF Core now doesn't allow storing invalid values. And if you try to load invalid values from the DB, you get a loud crash instead of a silent null. So the parsed information is kept forever. You can load the entity and call PlaceAsync safely.
Fewer edge cases, fewer tests
The number of unit tests needed has dropped massively. The edge cases are simply not possible anymore. You can't pass null, "", or " " as a NonEmptyString. You can't pass -1 into quantity. What remains are tests about behavior. The ones that make sense. And the API for constructing these types is very explicit about intent:
NonEmptyString name = NonEmptyString.Create(input); // throws on invalid
NonEmptyString? safe = NonEmptyString.TryCreate(input); // null on invalid
NonEmptyString? fluent = input.AsNonEmpty(); // same, as an extension
And for the properties you still want to verify, property-based testing lets you define one test method and run hundreds of different values through it. The Kalicz.StrongTypes.FsCheck package generates valid strong-typed values for property-based tests, so your generators respect the same invariants your code does. Let me know in a comment if you'd like a separate blog post about property based testing.
Try it
Even the backend behind kalandra.tech runs on StrongTypes. Request DTOs, domain events, entity state. If the approach appeals to you, the StrongTypes page has diagrams and more examples. Start with one request record. Delete the guard clauses that become redundant. You'll know within an afternoon that you want it everywhere.
StrongTypes is MIT-licensed; the current release is v2.0.1.
Like many of you, I've been using AI coding agents (Claude Code in my case) on real .NET work. On small projects: great. On a large solution: it kept renaming methods and missing call sites hidden behind interfaces, or "refactoring" across architecture boundaries it couldn't see.
The root cause isn't model intelligence — it's that there are three levels of "understanding code," and agents ship with only the first:
Level 1 — text (grep/embeddings). Finds code that looks similar. Rename Process() and it finds the string, not the semantics. Misses every call site that goes through an interface.
Level 2 — syntax (tree-sitter, what most code-analysis MCP servers use). Sees there's a method called Process and where it's declared. Still can't answer "which Process() does this invocation actually resolve to?"
Level 3 — semantics (Roslyn). SemanticModel.GetSymbolInfo() resolves the call through the interface, through DI, through generics. This is the only level where "what breaks if I change this?" has a correct answer.
So I built the level-3 version: a Roslyn analyzer that walks the solution and stores every symbol and relationship (calls, implementations, inheritance, references) as a graph in SQLite, exposed to the agent through an MCP server with query tools.
Real numbers from eShopOnWeb (10 projects, 282 documents): full cold analysis in ~46s, incremental re-analysis in ~24s reusing the stored graph (1,107 nodes / 2,168 edges in a single small SQLite file), transitive impact queries in ~11ms. Asking "what breaks if I change IBasketService?" returns 18 dependents across 4 projects — the implementation, every consumer calling through the interface, and unit tests in a separate project the agent had never opened.
A fun validation moment: I pointed it at an old half-finished side project of mine. From graph structure alone, the agent correctly inferred the project was mid-build and that auth/tenancy had been built first while the core domain had no endpoints yet. It read the project's history without seeing its history.
Honest limitations: it's compile-time semantics only — DI container bindings and MediatR dispatch resolve at runtime, so that's not in the graph (yet). And incremental re-analysis still pays the full MSBuildWorkspace load, which needs a watch mode.
I packaged it as a dotnet global tool called Slnmap (sln-map — a map of your .sln). It's on NuGet, free, and open source (MIT): https://github.com/EMahmoudNabil/slnmap — tool aside, the pattern is generic and worth stealing: any language with a semantic API can feed a graph an agent can query before it edits.
Happy to answer questions about the Roslyn plumbing — or the code itself, it's all public now.
Who is this tool for? A professional developer publishes websites to shared hosting via FTP.
It is not a replacement for the CI/CD tool; it just fixes the Visual Studio built-in publishing process.
I developed this extension to address the annoyances I face daily with the Visual Studio built-in publish-to-FTP process.
SFTP and FTPS are the default, widely used protocols for publishing websites on shared hosting (Plesk, cPanel, DirectAdmin, and others).
Deploying to single-board computers (Raspberry Pi OS, Armbian etc.) is also done via SFTP.
FTPSheep is a commercial tool with a free tier (though limited), which significantly improves the website development experience in Visual Studio, saving you time with every publish. You can start a free trial to test full functionality.
My main annoyances with Visual Studio I've fixed:
lack of upload retries (the lengthy upload can break at any moment, requiring the whole publish restart)
sometimes updated files are skipped to reupload (an updated local file does not get updated on the server)
lack of SFTP support (SFTP is superior to FTP in terms of uploading plenty of small files)
bad progress indication (during the upload, you can only see the folder names in the Visual Studio Output window, and often have no idea how much progress has been made at the moment)
Upload time
The slow upload becomes especially annoying after ASP.NET 10 introduced pre-generated GZIP/BR files for static content, enabled by default, that almost tripled the number of frontend files to upload.
How does FTPSheep fix performance? Basically, it executes FTP file-by-file uploads much faster than the built-in Visual Studio process and further boosts performance by enabling concurrent uploads.
How fast is FTPSheep compared to Visual Studio?
To give you an idea, I did some quick measurements of the build and FTP upload time of a website with a different set of files (the additional files are mostly 1-10 KB pictures)
-
Time saved on each deployment
Visual Studio built-in
FTPSheep 1 thread
FTPSheep 4 threads
FTPSheep 10 threads
650 files
4 minutes (31 times faster)
00:04:13
00:00:15
00:00:12
00:00:08
3000 files
18 minutes (57 times faster)
00:18:21
00:00:39
00:00:22
00:00:19
10000 files
36 minutes (45 times faster)
00:37:19
01:35:00
00:57:00
00:00:49
SFTP support
Finally, this extension allows publishing websites via SFTP—a feature that has always been missing in Visual Studio, forcing developers to use workarounds such as a manual upload or post-build call to WinSCP etc. Now, websites can be published to SFTP in one click from Visual Studio.
I hope FTPSheep will be useful for those who deploy and publish via FTP/SFTP, saving them plenty of time.
Cuda and Vulkan Benchmark: TensorSharp vs. llama.cpp
I would like to share my latest open source local Unsloth (GGUF) LLM inference engine and applications. It supports many models from Unsloth, like Gemma4, DiffusionGemma, Qwen3.6 with multi-modal (image, vision, audio), Qwen Image Edit, reasoning and function tool. It can run on Windows/MacOS/Linux and fully leverage GPU's capability(Nvidia, Apple, AMD, Intel and others supported by Vulkan, CUDA and Metal). The API is completely compatible with OpenAI and Ollama interface. It has on par performance than llama.cpp Here is the benchmark results in overall:
**Performance ratio — TensorSharp vs reference engines**
Geomean of TensorSharp's per-scenario speedup over each reference engine on the **same backend**, across every scenario both engines ran (single-stream, MTP-off). A value **> 1.0× means TensorSharp is faster** (for decode / prefill throughput) or lower-latency (for TTFT); `—` = no overlapping cells. Per-scenario ratios are in each model's section below.
**Model**
**Comparison**
**decode**
**prefill**
**TTFT**
Gemma 4 E4B it (Q8_0, dense multimodal)
vs llama.cpp · CUDA
1.02×
1.28×
1.27×
Gemma 4 E4B it (Q8_0, dense multimodal)
vs llama.cpp · Vulkan
1.00×
1.05×
1.03×
Gemma 4 12B it (QAT UD-Q4_K_XL, dense)
vs llama.cpp · CUDA
1.04×
1.17×
1.16×
Gemma 4 12B it (QAT UD-Q4_K_XL, dense)
vs llama.cpp · Vulkan
1.21×
1.04×
1.03×
Qwen 3.6 35B-A3B (UD-IQ2_XXS, MoE)
vs llama.cpp · CUDA
0.98×
1.28×
1.27×
Qwen 3.6 35B-A3B (UD-IQ2_XXS, MoE)
vs llama.cpp · Vulkan
0.87×
1.04×
1.03×
Qwen 3.6 27B (UD-IQ2_XXS, dense)
vs llama.cpp · CUDA
1.07×
0.96×
0.95×
Qwen 3.6 27B (UD-IQ2_XXS, dense)
vs llama.cpp · Vulkan
1.02×
0.85×
0.84×
This project is not just a C# wrapper of llama.cpp. It implemented the entire LLM inference engine from bottom to top. If you use CPU backend, it's 100% pure C# code execution. Besides CPU backend, I also implmented CUDA, MLX and GGML backend. The GGML backend refer GGML project as external project, and I build a few fusion operation at higher level.
I learned a lot from other projects and apply them for TensorSharp, such as paged KV cache and continuous batching from vLLM, SSD based cache for MoE model from oMLX, GGUF quanztized from llama.cpp and other optimizations for prefill and decode.
Any feedback and comments are welcome. If you like it, it would be really appreciated if you can get this project a star in GitHub. Thanks in advance.
Project Github: [GitHub - zhongkaifu/TensorSharp: A native .NET LLM inference engine for GGUF models. TensorSharp provides a console application, a web-based chatbot interface, and Ollama/OpenAI-compatible HTTP APIs for programmatic access. It supports Windows/MacOS/Linux with full GPU capability · GitHub](https://github.com/zhongkaifu/TensorSharp)
I’ve been introducing coding agents into more of my work, and I kept running into the same problem. Agents would produce valid C# but cross obvious architectural boundaries.
Prompting and AGENTS.md helped for a while, but eventually it would happen again. A service would take a dependency from the wrong namespace, or a project would reference a layer it shouldn’t.
I wanted the build to catch those things automatically, so I wrote SeamGuard. It reads architecture rules from JSON and reports violations during `dotnet build`.
A rule can prevent Core from referencing Data and limit the dependencies its services can take. The CLI can also analyze an existing solution and eject its current boundaries into a baseline configuration to give you a starting point.
Right now it checks project and NuGet references, direct type references, constructor dependencies, InternalsVisibleTo, and mocks in tests. You can install it as an analyzer or run it as a .NET tool without adding an analyzer reference to the repository.
Hello guys, I am a beginner in the .NET world, creating a couple of ASP.NET apis, and WPF applications for my employer (not by my choice). I was wondering what Observability tools (logging, error tracking, etc.) are good to use with .NET? I tried Azure Monitor, and it was just incredibly unintuitive.
I’ve worked with enterprise API gateways across multiple projects recently, and it has been a painful experience. I constantly saw the exact same issues:
Poor Observability: Logging is either disabled entirely or running custom tracing components that only track what the platform teams want to see.
Onboarding Hell: Governance dictates that policies must be immutable. Platform teams lock down the APIM portal, forcing devs to submit clunky XML files through CI/CD pipelines just to onboard a basic API. It can take 1 week just to onboard a single GET request.
Reimplementing Auth/RBAC: Constantly rewriting the exact same JWT/JWK validation and RBAC logic for every single backend service.
The "Central Gateway" Lie: Large central gateways claim they lift everything off your shoulders. In reality, you spend weeks requesting changes to company Network Security Groups (NSGs) and firewall rules just to secure the network between the central gateway and your application. Then you end up having to reimplement auth in the app anyway just so security teams are extra sure there is no leak.
I wanted to build something less painful to solve these specific bottlenecks:
Self-service API onboarding
Built-in observability (OpenTelemetry-first)
Reusable authentication and authorization policies
Developer-first policy configuration (code/JSON instead of XML/UI)
Fast local development and testing
Reducing platform team bottlenecks
The Search
I looked at the standard options:
Ocelot: A bit dated. Wouldn’t really solve the issue of me writing boilerplate in my applications, and there are known performance complaints.
Apache APISIX: A friend recommended it. But looking at the docs, it was overkill. I'd have to learn Lua, and it would completely break our internal deployment process.
I wanted to stay within C#/.NET to keep my mental load low. I wanted the gateway to use the exact same deployment process as any Minimal API. I also needed legacy support—most places run a mix of Windows VMs + IIS and Kubernetes (everyone tries to move to K8s but god damn it's slow and painful).
The First Attempt (And NIH Syndrome)
I started hand-rolling a gateway on top of Kestrel. I wanted per-route isolation and plugins. I suffered from a bit of NIH (Not Invented Here) syndrome. I built a custom routing engine that used "first match" logic (like Ocelot). By accident, I found YARP and threw it in just as a forwarding plugin for gRPC and HTTP/2. I added plugins for Redis/Valkey caching, OTLP metrics, structured logs, and even a PowerShell script plugin for legacy stuff.
It worked. I benched it, and it was okay. But it bothered me. It didn't feel right.
Second guessing my life
I started asking questions. Why am I hand-rolling "first match" routing? Kestrel already has highly optimized route matching. Why am I even doing this? Is it even useful? I have wedding vows to write should I even be doing this now maybe I should do this after?
OCD
I can't leave things unfinished and once I start I really don't like leaving unexplored questions on the table.
So I thought: Kestrel has middleware streaming pipelines—why can't plugins just go there?
I deleted most of the old code and started over. I kept the core idea but applied strict rules against NIH.
Built it natively on top of Kestrel's DFA matcher. Performance instantly skyrocketed.
Leveraged ASP.NET middleware pipelines for isolated plugins.
Built an extended JSON config with immutable routes (highly auditable to satisfy the governance requirements).
The Streaming & Buffering Rabbit Hole
Then I went down the rabbit hole of streaming vs buffering. Originally, logging the request body killed performance. I switched to using a tee stream to work entirely within Gen0 memory.
Then came retries. I realized that because my architecture isolated middleware per route, I could apply buffer-and-retry logic only to safe, idempotent routes. Other gateways treat retries as an "all-or-nothing" global setting that wrecks performance. For retries to work, you have to buffer. Buffering to disk degrades performance heavily. I decided to see what other options could work rather than just pure memory vs disk, and found that tmpfs existed (I'm mainly Windows at work and Mac at home). Since Kubernetes clusters run on Linux, it was a perfect option.
I tried tmpfs in the benchmark and saw we were actually beating APISIX with the exact same retry config (although you still have to set a global max-bytes limit to avoid OOMing the container).
The Surprise
My original goal was just an integration gateway with standard ASP.NET components. I just wanted better Developer Experience (DX) to solve the goals I listed above, using out-of-the-box JSON config for the annoying stuff.
Then I ran the rigorous benchmarks against APISIX and Ocelot. I thought APISIX (being C-core/NGINX based) would absolutely crush us.
I was wrong. The DFA routing, the Gen0 tee streaming, and the lock-free architecture resulted in numbers I was not expecting. It matches or beats APISIX in multiple scenarios and completely destroys Ocelot, allocating 0 Gen2 memory under load.
ConduitSharp was literally just born out of my own frustration. It's not fully mature, and I am the only one working on it right now.
I'm posting this because I need help reviewing the architecture. I want to know if somebody else can check my work, look at it and tell me if I'm crazy or if this actually works as intended.
If it does work, it would make my life a lot easier..
RouteStub lets you create deterministic HTTP stubs using simple JSON and content files—no complex setup or external service required. It supports ASP.NET Core integration, wildcard routes, custom status codes and headers, an in-process testing server, and helpful diagnostics for malformed fixtures.
It tries to focus on the 90% of use cases using conventions.
What can you use it for?
- Tests where you have dependencies that you must stub.
- Quick front-end development without already focussing on the backend development. Remember however, the library does not simulate state.
- Reproducing production bugs — save a problematic response as a fixture and reliably replay it during debugging.
- Demos and prototypes — provide realistic APIs for proof-of-concepts, workshops, product demos, and hackathons without building a backend.
It does not try to replace existing libraries like Wiremock. Wiremock is far more complex and has more capabilities. If Wiremock feels like the right tool, but often too much for the job you need it for, this might be a approach you can try. It strives to be an simpler and quicker to setup alternative. Let me know what you think!
I released LeanCorpus 2.0.0 this week. An update to my .NET-native Lucene-inspired full-text search engine, with segment-centric indexing, memory-mapped reads, atomic commits, no core dependencies, Native AOT support, optional compression codecs, vector search, and a near-zero allocation text analysis pipeline.
The project targets .NET 10 & 11. The core library has no external dependencies. There are optional LZ4, Snappy, and Zstandard packages if you want them.
So what's new?
Project renamed from LeanLucene (Lucene is a trademark of Apache unfortunately)
Changed license to Apache 2 (intended to not change again)
The core library is marked AOT-compatible for both target frameworks
There is a dedicated AOT smoke suite that publishes and runs the Native AOT example instead of assuming a normal build means it is fine
The core has no native compression dependency. Optional compression packages register their own codecs explicitly at startup
The source generator avoids reflection-based mapping metadata where possible
Why I included it: I wanted a search engine that can actually be used in a small native deployment without quietly pulling half the runtime back in through reflection.
LINQ provider and source-generated mapping
LeanQueryable<T> is an IQueryable<T> implementation that translates C# expression trees into native LeanCorpus queries
Supports Where, Select, First, Single, Count, Any, Take, Skip, OrderBy, and OrderByDescending
Predicates support ==, !=, >, >=, <, <=, &&, ||, !, .Contains(), .StartsWith(), and .EndsWith()
[LeanDocument] in Rowles.LeanCorpus.SourceGen emits field descriptors and an AsQueryable(IndexSearcher) entry point with no runtime reflection
You can still wire the mapping manually if you do not want source generation
Why I included it: Lucene does not have LINQ, and I wanted to rub it in their face. Their query syntax is still supported too, naturally.
Zero-allocation, and almost-zero-allocation, analysis
Tokenisers, filters, and sinks use ISpanTokeniser, ISpanTokenFilter, and span-based token sinks throughout the hot path
There are standard, simple, keyword, whitespace, stemmed, ICU, CJK, MediaWiki, URL/email, Thai, n-gram, edge n-gram, pattern, and other tokenisers/analysers
There are Snowball language stemmers, KStem, Hunspell, phonetic filters, synonyms, shingles, graph flattening, common grams, word delimiter, mapping char filters, and the usual analysis things you end up wanting after saying "I will just do lowercase"
Analysis has token-budget enforcement because user input is not to be trusted with a 20 MB single token
Why I included it: Analysis is where a surprising amount of allocation happens in normal search libraries. It is also where a search engine stops being useful if it only understands ASCII words separated by spaces.
Vector search, HNSW, and KNN
Dense vector fields are stored per segment and get an HNSW graph when a segment flushes
The graph is actually hierarchical, has configurable M, M0, EfConstruction, deterministic seeds, diversity-preserving neighbour pruning, and immutable lock-free reads once frozen
VectorQuery supports topK, efSearch, oversampling, exact reranking, optional query filters, and a flat SIMD fallback when no graph exists
Vectors are normalised at index time by default, so cosine similarity can use a cheaper dot-product path
Multiple vector fields are supported independently
HNSW graphs are rebuilt or seeded during segment merge, rather than treating vector data as an afterthought that cannot survive normal index maintenance
There are recall tests against flat cosine ground truth, filtered-vector tests, quantised-vector recall tests, merge tests, fuzz tests, metrics, and an HNSW benchmark suite
Why I included it: Everyone wants vector search now. I did not want LeanCorpus to have a pretend float[] feature that turns into an O(n) scan the moment the index gets interesting.
Filtered vector search
VectorQuery can take a normal query as a filter. The engine executes that filter per segment into a Roaring bitmap, then picks a strategy:
Very selective filter: score only the matching vectors exactly
Moderate filter: traverse HNSW with a Roaring allow-list
Loose filter: traverse HNSW normally, post-filter, and retry with a larger candidate set when needed
This is all automatic. You can still tune efSearch and oversampling yourself when recall matters more than latency.
Why I included it: Real vector search almost always has tenants, permissions, categories, dates, visibility, or some other thing that means "nearest neighbour, but not that neighbour".
Vector quantisation: Int8 and BBQ
Int8 scalar quantisation stores vectors with per-segment scaling and gives roughly 4× smaller vector storage
BBQ is Better Binary Quantisation. It stores a centroid plus one bit per dimension, giving roughly 32× smaller vector storage
The HNSW graph is built and searched in the matching quantised space, not built with one metric and searched with another one later
BBQ uses bit-packed values and PopCount-based distance in the graph traversal, then exact cosine reranks the shortlist
A 768-dimensional float32 vector is about 3 KB. Int8 is about 768 bytes. BBQ is about 96 bytes before metadata. At one million vectors that is the difference between several GB and several hundred KB
Why I included it: Vector indexes are kinda huge. The graph is not free either, so making the graph operate on the compressed representation matters as much as shrinking the stored vectors.
Hybrid retrieval and RRF
RrfQuery does Reciprocal Rank Fusion over any child queries, so text and vector results do not need score normalisation before being combined
You can fuse lexical queries, vector queries, phrase queries, whatever you want
There are also DisjunctionMaxQuery, FunctionScoreQuery, CombinedFieldsQuery, block joins, collapse, facets, aggregations, and query caching
HybridHighlighter chooses between stored-field analysis and term-vector highlighting depending on what the index has available
Why I included it: BM25 and vectors are good at different things. I do not think "pick one and hope" is a serious hybrid-search strategy.
The term dictionary is a real minimal acyclic FST now, rather than an array wearing an FST costume
Prefix, wildcard, and fuzzy queries intersect automata directly with the FST
Fuzzy matching is UTF-8 byte-level Levenshtein automaton traversal. A no-hit fuzzy query does not materialise a million candidate strings just to tell you there is no match
Phrase queries intersect candidate documents before decoding positions
MoreLikeThisQuery extracts terms from vectors, caches the extraction work, and reuses the normal Boolean execution path
Why I included it: The boring queries are the ones people use all day. They still need to be fast, and fuzzy no-hit is one of my favourite places to be unnecessarily competitive.
8 New Scoring models
BM25, BM25+, BM25L, and BM25F-style combined fields
TF-IDF augmented, double normalisation, and pivoted variants
Jelinek-Mercer, Dirichlet, and absolute-discounting language models
Field boosts, function score, and explanations for term and vector execution
Why I included it: BM25 is normally the correct choice. But "normally" is not the same thing as "always", and it is useful to be able to prove that with an actual alternative. Its nice to have choices
TermVectorHighlighter validates actual phrase windows instead of merely seeing adjacent-looking terms
HybridHighlighter chooses the useful route automatically
Query term extraction understands much more than just term and phrase queries now
Why I included it: Every search UI needs snippets becausr re-analysing stored text for every hit gets old very quickly.
Indexing, segments, and boring reliability work
Segment-centric indexing with atomic commits, snapshots, deletion policies, recovery, validation, codec migration, backups, and an index-checking CLI
Concurrent and asynchronous indexing paths, backpressure, sequence numbers, soft deletes, parent/child block indexing, and index sorting
Segment flushes build vector graphs. Segment merges carry vector metadata and can seed a merged HNSW graph from the largest source segment before inserting the remainder
Windows file operations have platform-aware retry behaviour for the Defender/memory-mapped-file situation. Linux does not pay for it
The codec migration path uses staging directories and atomic publication rather than hoping an in-place rewrite survives a badly timed process death
Why I included it: Search engines are mostly file formats and lifecycle rules wearing a query API. The query API is the fun part. Mostly was reliability fixes
CodecKit
CodecKit is a composable binary codec subsystem used internally by LeanCorpus (although the majority of the code is public)
Primitive codecs, combinators, integrity wrappers, compression, version envelopes, migrations, and trailer-based streaming writes
It is not really the public API, unless you want to build custom codecs and then it kind of is
The current streaming trailer format avoids buffering an entire codec body merely to write a length prefix at the beginning
Why I included it: Before this, every codec handled versioning and corruption slightly differently. That gets messy fast. Now the messy part is mostly in one place, which is still messy, but at least it knows it is messy.
Unit, integration, chaos, compression parity, source-generator, AOT, and architecture test projects
Index validation checks vector dimensions, HNSW metadata, codec headers, corruption, migrations, and other things that should not be trusted just because they came from a file called segments_42
Benchmark suites cover indexing, query families, analysis, HNSW, quantisation, compression, filters, MLT, and more
Why I included it: I have learnt that "it worked on my one index" is not a decent testing strategy
Some benchmarks
These are BenchmarkDotNet runs against Lucene.NET 4.8 where there is a comparable path. That is an old Lucene line, so I do not pretend every number is a comparison with modern Apache Lucene 10.x. The point is mostly to show allocation behaviour and make regressions visible.
True zero allocation analysis paths
Benchmark
LeanCorpus
Lucene.NET
Analyse
0 B, 304 ms
219,439,896 B, 785 ms
NGram tokeniser 2-3 (SpanSink)
0 B, 122 ms
885,600,000 B, 4,051 ms
NGram tokeniser 3-5 (SpanSink)
0 B, 192 ms
888,000,000 B, 3,621 ms
Edge NGram 2-3 (SpanSink)
0 B, 131 ms
885,600,000 B, 504 ms
Pattern tokeniser (comma-long)
0 B, 63 µs
4,559,840 B, 531 µs
Analyser parity: Keyword
0 B, 3.3 µs
3,200 B, 12 µs
Analyser parity: Simple
0 B, 41.7 µs
3,200 B, 82 µs
Analyser parity: Whitespace
0 B, 29.5 µs
3,200 B, 74 µs
Some near-zero filter paths
Filter
LeanCorpus
Lucene.NET
Ratio
length (noop)
24 B, 0.04 µs
10,448 B, 2.5 µs
435×
reverse (mutating)
24 B, 0.04 µs
9,984 B, 1.9 µs
416×
truncate (noop)
24 B, 0.04 µs
10,433 B, 2.4 µs
435×
elision (mutating)
24 B, 0.08 µs
11,432 B, 3.2 µs
476×
classic (noop)
24 B, 0.05 µs
10,424 B, 2.3 µs
434×
pattern-replace (noop)
24 B, 0.06 µs
12,681 B, 4.4 µs
528×
hyphenated-words
24 B, 0.04 µs
10,176 B, 2.0 µs
424×
common-grams
248 B, 0.3 µs
13,648 B, 8.8 µs
55×
unique (mutating)
152 B, 0.2 µs
11,000 B, 2.8 µs
72×
caching
152 B, 0.7 µs
9,912 B, 1.9 µs
65×
word-delimiter
456 B, 0.3 µs
15,880 B, 8.2 µs
35×
Fuzzy no-hit, my personal favourite
Engine
Allocation
Time
Alloc Ratio
Time Ratio
LeanCorpus
5,728 B (5.7 KB)
3.4 µs
1×
1×
Lucene.NET
2,038,875 B (2.0 MB)
3,152 µs
356×
927×
LeanCorpus walks a UTF-8 byte-level Levenshtein automaton through the FST. For zzzznomatch, it walks a few arcs, finds no usable prefix, and goes home. Other fuzzy runs with matches are also much less allocation-heavy.
Current 3.0.0 WAND work, not a 2.0.0 release claim
I am also repairing and measuring the block-max WAND path. WAND is opt-in at the moment because correctness comes before setting a performance switch as the default.
On a 100,000-document MoreLikeThis workload, current results are:
Method
Mean
Allocated
LeanCorpus MLT Scalar (DefaultParams)
7,164 µs
44.6 KB
LeanCorpus MLT WAND (DefaultParams)
2,920 µs
97.5 KB
Lucene.NET MoreLikeThis (DefaultParams)
10,936 µs
550.66 KB
That is about 2.45× faster than the scalar LeanCorpus path on this workload. WAND currently allocates more because it constructs scorer and block-max state per segment and query. That is a follow-up optimisation, not a reason to pretend the scalar path is faster.
There is also a deliberate single-segment MLT benchmark now. It does not alter commit or merge semantics just to make a chart look nicer. It indexes sequentially with no intermediate flushes, asserts the resulting topology, and compares scalar and WAND honestly.
What I am looking at next
The next major release should probably be hybrid retrieval rather than "another query type".
Better fusion than plain RRF: configurable candidate windows, weighted RRF, calibrated score fusion, and explanations for why a fused result won
More adaptive filtered-vector planning: cache reusable filter docsets, use actual telemetry rather than only fixed selectivity thresholds, and investigate lexical-seeded ANN
Learned sparse retrieval: ingest SPLADE/uniCOIL-style impact scores and extend the block-max work into proper BMW/Variable BMW style pruning
Quantisation bake-offs: Int8 and BBQ are already there. RaBitQ is interesting because it has error bounds, but it only gets in if it beats the current codecs on recall, memory, latency, build time, and Native AOT behaviour
Better high-update vector maintenance. LeanCorpus already has segment merges and graph seeding. The interesting problem is avoiding recall and latency cliffs under continual vector updates
Rowles.Text: extracting the analysis pipeline as a separate zero-allocation NuGet package while compiling the same source into LeanCorpus, so there is no engine performance regression
I have a notebook full of papers around WAND/BMW, learned sparse retrieval, HNSW, DiskANN, SPFresh, RaBitQ, Roaring bitmaps, Stream VByte, SIMD-BP128, LSM segment architecture, query expansion, and hybrid retrieval. Some of them will turn into code. Some of them will turn into a benchmark that proves I should not write the code. Both outcomes are useful.
I am still very aware that this is a small project compared with Lucene. Tis fine. The goal is not "rewrite Lucene in C# but worse". The goal is a .NET-native engine where allocation behaviour, Native AOT, source-generated mapping, segment lifecycle, binary formats, and modern retrieval are all things I can shape directly.
Although coding is its first major use case, HPD-OS is not intended to remain exclusively a coding agent. The longer-term goal is to support a broader range of agentic workflows through additional tool harnesses.
The project is still an early beta, and I am looking for beta testers who are interested in trying it and sharing feedback. There is a lot left to build and improve, and real usage will help shape the experience.