DEV Community

Cover image for C# to Go: A .NET Developer's Translation Map
Gabriel Anhaia
Gabriel Anhaia

Posted on

C# to Go: A .NET Developer's Translation Map


.NET 9 finally solved your deployment pain. Single-file AOT, a trimmed runtime, 30 MB container images, cold starts that no longer embarrass you in a demo. You still look at Go and wonder. A colleague on the platform team keeps telling you the CI pipeline for their Go service is eleven lines of YAML. Their staging deploys take fourteen seconds. Their container image is 8 MB and does not need a base image.

Here is what changes if you cross the bridge, feature by feature, in the shape a .NET engineer already has in their head.

This is not an argument that Go is better than C#. C# is still a more expressive language. Go is a smaller one, on purpose, and that smallness is most of why teams end up migrating. The rest of this post is the translation map. What does your WebApplication.CreateBuilder(args) muscle memory map to. What happens to your DbContext. Where does your async keyword go. What replaces the DI container you have been using since Autofac was a big deal.

flowchart LR
    subgraph DOTNET["ASP.NET Core"]
        A1[Program.cs] --> A2[AddScoped / AddSingleton]
        A2 --> A3[Service provider]
        A3 --> A4[Controller resolves deps]
    end
    subgraph GO["Go"]
        G1[main] --> G2[NewRepo db]
        G2 --> G3[NewService repo]
        G3 --> G4[NewHandler service]
    end
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core to net/http plus chi

ASP.NET Core's minimal APIs are the closest thing .NET has to Go's HTTP story. You already write endpoints as lambdas, pull dependencies by parameter, and return results. The Go equivalent is the standard library net/http package, with a router library on top because the stdlib mux is fine but minimal. The two routers people actually reach for are chi and echo. Chi is idiomatic stdlib-shaped; echo is a batteries-included framework. I will use chi here because it is the one you will see in most production Go codebases in 2026.

The ASP.NET Core minimal API:

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<UserRepository>();
var app = builder.Build();

app.MapGet("/users/{id:int}", async (int id, UserRepository repo) =>
{
    var user = await repo.FindAsync(id);
    return user is null ? Results.NotFound() : Results.Ok(user);
});

app.Run();
Enter fullscreen mode Exit fullscreen mode

The Go equivalent:

// main.go
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"

    "github.com/go-chi/chi/v5"
)

func main() {
    repo := NewUserRepository()
    r := chi.NewRouter()

    r.Get("/users/{id}", func(w http.ResponseWriter, req *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(req, "id"))
        if err != nil {
            http.Error(w, "bad id", http.StatusBadRequest)
            return
        }
        user, err := repo.Find(req.Context(), id)
        if err != nil {
            http.Error(w, "not found", http.StatusNotFound)
            return
        }
        _ = json.NewEncoder(w).Encode(user)
    })

    log.Fatal(http.ListenAndServe(":8080", r))
}
Enter fullscreen mode Exit fullscreen mode

Two things stand out on the Go side. The first is that model binding is manual. ASP.NET Core parses {id:int} for you, validates it, and hands you an int. In Go you parse the string yourself and decide what to do on parse failure. The second is that error handling is a return value, not an exception. Your if err != nil block is the catch you used to write, except it is inline and the compiler will remind you if you forget it.

What you give up: the magic. What you get back: a request handler you can read top to bottom without jumping to middleware, model binders, filter pipelines, or endpoint conventions.

Entity Framework Core to sqlc

This is the migration most .NET teams resist the hardest and then stop missing after a month.

EF Core gives you change tracking, migrations, LINQ-to-SQL translation, lazy loading if you want it, and a unit-of-work pattern baked in. The Go ecosystem has ORMs that try to match this (GORM is the popular one), but the pattern you see in production Go code is closer to the opposite: hand-written SQL, parsed into typed Go code by a tool.

The tool is sqlc. You write SQL. sqlc reads your schema and your query file, generates typed Go functions, and you call them. The SQL is the source of truth.

A query file:

-- queries.sql
-- name: FindUser :one
SELECT id, email, created_at
FROM users
WHERE id = $1;

-- name: CreateUser :one
INSERT INTO users (email)
VALUES ($1)
RETURNING id, email, created_at;
Enter fullscreen mode Exit fullscreen mode

Run sqlc generate. You get:

// generated: queries.sql.go (excerpt)
func (q *Queries) FindUser(ctx context.Context, id int32) (User, error) { /* ... */ }
func (q *Queries) CreateUser(ctx context.Context, email string) (User, error) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

The User struct is generated from the schema. Column types, nullability, everything. Change the column type in a migration, regenerate, and the compiler tells you which call sites need updating. This is the same feedback loop EF migrations give you, minus the LINQ layer.

What you give up: the ability to compose queries in C#. No more users.Where(u => u.Active).OrderBy(u => u.Email).Take(10) fluently. What you get back: the query the database actually runs is the query you wrote, not a translated one. You stop debugging why EF generated an OUTER APPLY for a subquery that should have been a join.

LINQ to explicit loops plus samber/lo

LINQ is the piece .NET developers miss longest. .Where, .Select, .GroupBy, .Aggregate are muscle memory. Go's standard library has generics since 1.18 but does not ship a LINQ-equivalent. The idiomatic move is to write the loop.

var active = users
    .Where(u => u.Active)
    .Select(u => u.Email)
    .ToList();
Enter fullscreen mode Exit fullscreen mode

The loop version:

active := make([]string, 0, len(users))
for _, u := range users {
    if u.Active {
        active = append(active, u.Email)
    }
}
Enter fullscreen mode Exit fullscreen mode

Three lines became six. The trade is that the six lines have no allocations hidden behind method chains, no deferred execution surprises (nobody has ever been bitten by IEnumerable evaluating twice in Go, because Go does not have IEnumerable), and the profiler points at exactly the line that is slow.

For the cases where you genuinely want the LINQ shape, samber/lo is the library you reach for. It is the closest thing Go has to LINQ or Lodash.

import "github.com/samber/lo"

active := lo.FilterMap(users, func(u User, _ int) (string, bool) {
    return u.Email, u.Active
})
Enter fullscreen mode Exit fullscreen mode

Most Go codebases end up using a mix: loops in the hot paths and when the transformation is one step, lo when the chain is three or more steps and reads better as a pipeline.

async/await to goroutines and channels

C# developers carry async/await as a reflex. Every I/O method gets await, every method that awaits gets async, the compiler rewrites the whole thing into a state machine. It works, and once you have internalized the rules (ConfigureAwait, sync contexts, cancellation tokens), it is hard to give up.

Go takes the other route. There is no async keyword. There is no await keyword. Every function is callable from any goroutine. You spawn a goroutine with go f(), and if you want to wait for it to finish or get a result back, you use a channel or a sync.WaitGroup.

C# fan-out:

var tasks = urls.Select(url => httpClient.GetStringAsync(url));
var bodies = await Task.WhenAll(tasks);
Enter fullscreen mode Exit fullscreen mode

Go fan-out:

type result struct {
    body string
    err  error
}

results := make(chan result, len(urls))
for _, url := range urls {
    go func(u string) {
        resp, err := http.Get(u)
        if err != nil {
            results <- result{err: err}
            return
        }
        defer resp.Body.Close()
        body, err := io.ReadAll(resp.Body)
        results <- result{body: string(body), err: err}
    }(url)
}

bodies := make([]string, 0, len(urls))
for range urls {
    r := <-results
    if r.err != nil {
        return nil, r.err
    }
    bodies = append(bodies, r.body)
}
Enter fullscreen mode Exit fullscreen mode

Longer. But notice what is missing: there is no "colored function" problem. http.Get is callable from any code, synchronous or spawned. You never have to mark a function async to call an async thing. A library you did not write does not force your code into a color.

This is where C# genuinely does better. Task.WhenAll with LINQ is four lines. Go's version is a small coordination dance that you will end up extracting into a helper function. For I/O-bound code, C# async ergonomics are still the best in class across mainstream languages. What Go trades that away for is uniformity: every function is the same kind of callable, and the scheduler is the runtime's problem, not yours.

DI container to explicit constructors

You probably use IServiceCollection and AddScoped. Or Autofac if you are on an older codebase. Either way, the container is where your object graph lives.

Go does not have a DI container as a default. It has constructors, and you wire them in main.

func main() {
    db := must(pgxpool.New(ctx, os.Getenv("DATABASE_URL")))
    repo := NewUserRepository(db)
    svc := NewUserService(repo, NewMailer(...))
    handler := NewHTTPHandler(svc)

    http.ListenAndServe(":8080", handler)
}
Enter fullscreen mode Exit fullscreen mode

Every dependency is an explicit argument. Every lifetime is whatever the variable's scope is. There is no AddScoped vs AddSingleton decision to make because the composition is code you can read. If the graph gets big, libraries like wire (compile-time) or fx (runtime) exist, but most codebases never reach for them. Three hundred services is still "wire it in main."

What you give up: automatic resolution of deep graphs. What you get back: you can click-through from main.go to every dependency in your app, and nothing is resolved reflectively at startup.

Nullable reference types to zero values plus pointers

C# 8 gave you nullable reference types. The compiler warns you when a string could be null. You end up sprinkling ? and ! until the warnings go away.

Go has no null. It has zero values ("" for string, 0 for int, nil for slice/map/pointer) and it has pointers. Optionality is a pointer: *string means "maybe a string." Reading a nil pointer panics, which is Go's equivalent of NullReferenceException except the compiler forces you to dereference explicitly (*ptr), so the panic is almost always at the exact line you forgot to check.

public record User(int Id, string Email, string? DisplayName);
Enter fullscreen mode Exit fullscreen mode
type User struct {
    ID          int
    Email       string
    DisplayName *string // nil means "no display name"
}
Enter fullscreen mode Exit fullscreen mode

The JSON library knows about this. A *string field marshals as null when nil and as the value when set, which lines up with how you already think about nullable fields in API contracts.

Records to structs, pattern matching to type switch

C# records give you value semantics, with expressions, and pattern matching for free. Go has structs, and structs are always value types. Pattern matching is not a language feature; Go has a switch statement with type-switch shape.

public record Event;
public record UserCreated(int Id, string Email) : Event;
public record UserDeleted(int Id) : Event;

string Describe(Event e) => e switch
{
    UserCreated uc => $"created {uc.Email}",
    UserDeleted ud => $"deleted {ud.Id}",
    _              => "unknown",
};
Enter fullscreen mode Exit fullscreen mode

The Go equivalent:

type Event interface{ isEvent() }

type UserCreated struct {
    ID    int
    Email string
}
func (UserCreated) isEvent() {}

type UserDeleted struct{ ID int }
func (UserDeleted) isEvent() {}

func describe(e Event) string {
    switch v := e.(type) {
    case UserCreated:
        return fmt.Sprintf("created %s", v.Email)
    case UserDeleted:
        return fmt.Sprintf("deleted %d", v.ID)
    default:
        return "unknown"
    }
}
Enter fullscreen mode Exit fullscreen mode

Wordier. The isEvent() marker method is the trick you use to get a sealed-union shape out of an interface. It works and the compiler catches the case where you pass something that is not an Event. What you lose is the expression-form syntax and record deconstruction. What you gain is nothing clever: these are just values and interfaces, same as the rest of your code.

Attributes to struct tags

[JsonPropertyName("email_address")] is how you customize serialization in .NET. Go puts the same information in a backtick-quoted string after the field.

type User struct {
    ID    int    `json:"id"`
    Email string `json:"email_address"`
}
Enter fullscreen mode Exit fullscreen mode

Struct tags are just strings. Every serializer, validator, and ORM reads them by reflection. The syntax is ugly the first time you see it and forgettable the second time.

What C# still does better

The migration is not all wins. Three places where C# is the more comfortable tool:

  • Tooling depth. Rider and Visual Studio are still ahead of what Go's tooling (gopls, Goland) gives you on refactoring and navigation for large codebases. Rider's rename-across-solution is a click; Go's LSP rename works but is not as deep. Go's build speed partially compensates (fast compile times make "edit, run, grep" a viable refactor loop) but the IDE gap is real.
  • Async ergonomics for I/O-bound code. For a service that is almost entirely "await this, await that, compose the results," C#'s syntax is shorter and the mental model is clean. Go's goroutine-plus-channel choreography is more flexible (you get CSP, cancellation, and streaming for free) but takes more code for the simple cases.
  • Language expressiveness. Records, pattern matching, LINQ, expression-bodied members, default interface methods. C# is a bigger language and rewards the engineer who knows it well. Go is deliberately small and rewards the engineer who writes plainly.

Why teams migrate anyway

The reason teams move is rarely "Go is a better language." It is usually one of three infrastructure-shaped reasons.

Deployment simplicity is first. go build produces a single static binary. No runtime to install. No base image you need (FROM scratch works). The container image is the binary plus a few CA certs. Deploys are scp plus systemctl restart, or a 6 MB layer in your registry. Your .NET 9 AOT binary is close to this, honestly, but Go has been here since 2009 and every tool around it assumes this shape.

Smaller runtime is second. A Go HTTP service idles around 15 MB of RSS. An ASP.NET Core one with AOT and trimming can get down to 30-50 MB. Without AOT, you are back at 100+ MB. If you are running hundreds of small services on a shared cluster, the density difference adds up.

Cross-platform builds are third. GOOS=linux GOARCH=arm64 go build is a thing you can run from macOS and get a working Linux ARM64 binary out the other end. No cross-compilation toolchain setup. No multi-stage Dockerfiles. This matters more than it sounds like it does the first time you need to ship to Graviton or a Raspberry Pi fleet.

These are infrastructure reasons. They are the same reasons people moved to Go from Java and Ruby a decade ago. The AI backend wave of 2026 has put a fresh set of teams in the position of evaluating it again, because agent services and LLM gateways have the same shape: network-heavy, concurrent, deployed dense.

If your .NET service is a monolith that is not going anywhere, this migration is not worth thinking about. If you are splitting a platform into services that live in Kubernetes and you are already writing YAML and Dockerfiles every week, Go removes a layer of your build pipeline and a layer of your runtime. That is the trade the platform teams making the jump are making.

Picking your first Go service

If you decide to try it, do not rewrite the monolith. Pick a service that is mostly I/O fan-out, small surface area, and already behind an HTTP boundary. A gateway. A webhook receiver. An internal reporting endpoint. Write it in Go, ship it, operate it for a quarter. You will know by month two whether your team wants the rest.

And if you do not: nothing wrong with staying on .NET 9. The gap closed more than the Hacker News threads admit.


flowchart LR
    subgraph LINQ["C# LINQ"]
        L1[users] --> L2[.Where a => a.Active]
        L2 --> L3[.OrderBy u => u.Name]
        L3 --> L4[.Take 10]
        L4 --> L5[.ToList]
    end
    subgraph GOL["Go explicit"]
        G1[for _, u := range users] --> G2[if u.Active]
        G2 --> G3[out = append out, u]
        G3 --> G4[sort.Slice out]
    end
Enter fullscreen mode Exit fullscreen mode

If this was useful

Thinking in Go is the 2-book series I wrote on Go for backend engineers. The first book is the language foundation, and the second book is hexagonal architecture applied to Go services, which is the shape a lot of .NET teams end up wanting when they migrate.

The other book, Observability for LLM Applications, is the one on running LLM-powered Go services in production. Same audience, one abstraction layer up.

Thinking in Go — 2-book series on Go programming and hexagonal architecture

Observability for LLM Applications — the book

Top comments (0)