Go for .NET Developers

A mental model for .NET backend developers learning Go: less framework, more explicit composition, simpler runtime, visible SQL, and direct error handling.


If you come from .NET backend development, Go can feel strange because it does not give you the same application platform feeling as ASP.NET Core.

In .NET, you often live inside a framework that provides structure: controllers or minimal APIs, dependency injection, middleware, model binding, configuration, logging, hosted services, EF Core, migrations, and a familiar project layout.

Go starts from a different idea: the language and standard library should be small, compilation should be fast, binaries should be simple to deploy, and application structure should be explicit instead of framework-driven.

That does not mean Go is primitive. It means Go expects you to see more of the wiring.

The Main Mental Shift

The most useful comparison is this:

.NET asks: "Which framework extension point should this code plug into?"

Go asks: "Which plain function or type should own this behavior?"

In .NET, a controller might receive a service from dependency injection:

public sealed class UsersController(IUserService users) : ControllerBase
{
    [HttpGet("{id}")]
    public async Task<ActionResult<UserDto>> Get(Guid id)
    {
        var user = await users.GetById(id);
        return Ok(user);
    }
}

There is a lot happening around this small method:

  • ASP.NET Core creates the controller.
  • The DI container provides IUserService.
  • Routing finds the method.
  • Model binding converts the route value into Guid id.
  • Middleware handles auth, logging, exceptions, and other cross-cutting behavior.
  • The runtime handles async work through Task.

In Go, you should expect to wire more of that yourself. That is why Go examples often show values being created and passed around. It is not random boilerplate. It is the application being assembled in normal code.

What Does "Pass It To The Store" Mean?

A confusing Go example often looks like this:

store := NewUserStore(db)

If you are new to Go, the natural question is: why are we doing this manually?

Think of UserStore as the thing that owns user database access. In .NET, that might be a repository, an EF Core DbContext, or a service that wraps the database.

The db value is the database connection pool. NewUserStore(db) creates a small object that says: "Here is the user data access code, and here is the database it should use."

A less compact version would look like this:

type UserStore struct {
    db *sql.DB
}
 
func NewUserStore(db *sql.DB) UserStore {
    return UserStore{db: db}
}

So this:

store := NewUserStore(db)

means:

  • create the user data access component
  • give it the database connection it needs
  • keep that dependency visible
  • pass the store to whatever code needs user data

.NET often hides this step inside the DI container. Go usually keeps it in front of you.

Web APIs Feel Lower-Level

ASP.NET Core gives you a full web application model. Go gives you net/http, which is intentionally small.

A Go HTTP handler is just a function that receives a request and writes a response:

func getUser(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("user response"))
}

The two arguments matter:

  • r *http.Request is the incoming request.
  • w http.ResponseWriter is how you write the response.

That is similar to what ASP.NET Core gives you through HttpContext, but Go makes it visible in the handler signature.

When you need dependencies, you normally create a type that holds them:

type UserHandler struct {
    store UserStore
}
 
func (h UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    user, err := h.store.GetByID(r.Context(), id)
    if err != nil {
        http.Error(w, "user not found", http.StatusNotFound)
        return
    }
 
    json.NewEncoder(w).Encode(user)
}

Read this mentally as:

  • UserHandler is like a controller.
  • store is the dependency the handler needs.
  • GetUser is like an action method.
  • r.Context() carries request cancellation and timeout information.
  • Errors are handled directly instead of being thrown as exceptions.

The wiring might then look like:

users := UserHandler{store: NewUserStore(db)}
mux.HandleFunc("GET /users/{id}", users.GetUser)

That is the part ASP.NET Core DI usually does for you.

EF Core Versus Go Data Access

EF Core is a high-level ORM. It gives you LINQ queries, change tracking, relationship mapping, migrations, and a unit-of-work style through DbContext.

var user = await db.Users
    .Where(user => user.Id == id)
    .SingleOrDefaultAsync();

The database query is expressed in C#. EF Core translates it to SQL and tracks entities for changes.

Go backend code often starts closer to SQL:

func (s UserStore) GetByID(ctx context.Context, id string) (User, error) {
    row := s.db.QueryRowContext(ctx, `
        select id, email
        from users
        where id = ?
    `, id)
 
    var user User
    err := row.Scan(&user.ID, &user.Email)
    return user, err
}

The mental shift is important:

  • In EF Core, you often model your domain and let EF generate SQL.
  • In Go, you often write SQL and map the result into a struct.
  • In EF Core, DbContext tracks changes.
  • In Go, nothing is tracked unless you build or choose something that tracks it.
  • In EF Core, transactions can be wrapped around SaveChangesAsync.
  • In Go, transaction boundaries are usually explicit.

There are Go ORMs, but many Go teams prefer SQL-first code because it is predictable and easy to inspect.

This idea is not foreign to .NET. If you have used Dapper, you have already seen a similar tradeoff:

const string sql = """
    select id, email
    from users
    where id = @Id
    """;
 
var user = await connection.QuerySingleOrDefaultAsync<User>(
    sql,
    new { Id = id }
);

Dapper does not try to become a full DbContext. It lets you write SQL, pass parameters safely, and map rows into C# objects. That is closer to how many Go services think about persistence.

So the real question is not "ORM or no ORM?" The question is: which abstraction makes this part of the system easier to understand?

An ORM can be the right choice when you want:

  • change tracking
  • relationship mapping
  • migrations
  • fast CRUD development
  • a consistent unit-of-work pattern

Direct SQL can be the right choice when you want:

  • the exact query to be visible
  • performance behavior to be easier to reason about
  • fewer surprises from lazy loading or generated SQL
  • a better understanding of what the database is actually doing
  • a small data access layer that maps clearly to the use case

For a lot of backend work, SQL is already the clearest language for describing the query. Go leans into that. Dapper shows the same idea can also be clean in C#.

Errors Are Values

In .NET, you usually use exceptions for unexpected failure:

var user = await users.GetById(id);

If something fails, the method might throw.

In Go, failure is normally returned:

user, err := store.GetByID(ctx, id)
if err != nil {
    return err
}

This looks repetitive at first. The benefit is that failure is visible at every call site. You do not have to wonder which methods might throw, because fallible functions usually return error.

Good Go code often follows this pattern:

  • detect the error close to where it happened
  • add useful context if needed
  • return it upward
  • convert it to an HTTP response at the API edge

Dependency Injection Is Not Gone

Go still has dependency injection. It just usually does not mean "use a container."

This is dependency injection:

handler := UserHandler{
    store: NewUserStore(db),
}

You created a dependency and passed it in. That is the same design goal as constructor injection in .NET, but without the container.

The reason this works well in Go is that interfaces are small and implicit:

type UserReader interface {
    GetByID(ctx context.Context, id string) (User, error)
}

Any type with that method satisfies the interface. The type does not need to declare it.

For testing, you can replace the real store with a fake implementation. The design idea is familiar; the mechanics are simpler.

Async And Concurrency

.NET uses Task, async, await, and CancellationToken.

Go uses goroutines and context.Context.

For ordinary web API work, do not start by thinking about goroutines. Start with context.Context.

func (s UserStore) GetByID(ctx context.Context, id string) (User, error) {
    return queryUser(ctx, id)
}

The context is how request cancellation, deadlines, and timeouts move through the call chain. If the client disconnects or the request times out, database calls that receive the context can stop.

The .NET equivalent idea is passing a CancellationToken:

await db.Users.SingleOrDefaultAsync(user => user.Id == id, cancellationToken);

The important habit in Go is: pass context.Context into work that touches IO, waits, calls another service, or should be cancellable.

Project Structure

.NET projects often grow around framework layers:

  • Controllers
  • Services
  • Repositories
  • Domain
  • Infrastructure

Go projects often start flatter:

  • cmd/api
  • internal/users
  • internal/orders
  • internal/platform/database

The Go instinct is to group by behavior and keep packages small. Do not copy a large Clean Architecture template into a tiny Go service on day one.

Start simple. Add boundaries when the code starts asking for them.

How To Think In Go

When learning Go as a .NET developer, keep these rules in your head:

  1. Prefer explicit wiring over hidden framework magic.
  2. Keep types small and behavior close to the data it uses.
  3. Treat errors as normal return values.
  4. Pass context through IO-heavy work.
  5. Write SQL when SQL is clearer than an abstraction.
  6. Use interfaces at the consumer side, not everywhere by default.
  7. Avoid architecture up front; let structure grow from real pressure.

The Practical Difference

.NET is a rich backend platform. It gives you strong conventions, a mature framework, excellent tooling, and a very productive path for business applications.

Go is smaller and more explicit. It gives you fast builds, simple deployment, straightforward runtime behavior, and code where the wiring is easy to see.

The best way to understand Go is not to ask "where is the ASP.NET Core feature?" Ask "what is the simplest plain code that makes this dependency, request, error, and database call visible?"

Once that clicks, Go starts to feel less like a missing framework and more like a deliberate tradeoff: fewer moving parts, more explicit ownership, and less distance between the code you write and the behavior that runs in production.