I am trying to figure out a good solution for managing database transactions in Golang and use the same transaction between different services.
Let's say I am building a forum, and this forum has posts and comments.
I have comments_count column on my posts table in the database, which tracks the number of comments for the post.
When I create a comment for a given post, I also need to update the posts table and increase the comments_count column of the post.
My project structure is made of couple of layers: database / business / web
Currently my code looks like this?
main.go
package main
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"net/http"
"vkosev/stack/db/repository"
"vkosev/stack/services"
"vkosev/stack/web"
)
func main() {
dbConString := "postgres://user:password@host:port/database"
dbPool, _ := pgxpool.New(context.Background(), dbConString)
postRepo := repository.NewPostRepository(dbPool)
commentRepo := repository.NewCommentRepository(dbPool)
postService := services.NewPostService(postRepo)
commentService := services.NewCommentService(commentRepo)
handler := web.NewHandler(postService, commentService)
mux := http.NewServeMux()
mux.HandleFunc("POST /comments/{postId}", handler.CreateComment)
_ = http.ListenAndServe(":8080", mux)
}
web.go
package web
type Handler struct {
postService *services.PostService
commentService *services.CommentService
}
func NewHandler(postService *services.PostService, commentService *services.CommentService) *Handler {
return &Handler{
postService: postService,
commentService: commentService,
}
}
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
postId := getPostIdeFromRequest(r)
comment := getCommentFromRequest(r)
newComment := h.commentService.Create(comment, postId)
err := h.postService.IncreaseCount(postId)
if err != nil {
// write some error message
}
writeJSON(w, http.StatusOK, newComment)
}
services.go
package services
type PostService struct {
postRepo *repository.PostRepository
}
func NewPostService(postRepo *repository.PostRepository) *PostService {
return &PostService{postRepo: postRepo}
}
func (ps *PostService) IncreaseCount(postId int) error {
return ps.postRepo.IncreaseCount(postId)
}
type CommentService struct {
commentRepo *repository.CommentRepository
}
func NewCommentService(commentRepo *repository.CommentRepository) *CommentService {
return &CommentService{commentRepo: commentRepo}
}
func (cs *CommentService) Create(comment models.Comment, postId int) *models.Comment {
return cs.commentRepo.Save(comment, postId)
}
repository.go
package repository
type PostRepository struct {
pool *pgxpool.Pool
}
func NewPostRepository(pool *pgxpool.Pool) *PostRepository {
return &PostRepository{pool: pool}
}
func (pr *PostRepository) IncreaseCount(postId int) error {
// call pr.pool and increase comments count for post with the given ID
}
type CommentRepository struct {
pool *pgxpool.Pool
}
func NewCommentRepository(pool *pgxpool.Pool) *CommentRepository {
return &CommentRepository{pool: pool}
}
func (cr *CommentRepository) Save(comment models.Comment, postId int) *models.Comment {
// call cr.pool and insert comment into the DB
}
I initialize all the necessary dependencies in main.go and inject them into where I need them and then use handlers to handle every route.
Now I need a transaction, so that If for some reason I fail to update the comments count of a post to rollback the creation of the comment.
I guess the easiest way is to just pass Tx into the methods, but it seems ugly.
I was hoping for someway to abstract the database logic, so that the repositories does not care if they are using transaction or not.
And also to manage the transaction in the handler methods.
So that I could have something like this:
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
postId := getPostIdeFromRequest(r)
comment := getCommentFromRequest(r)
// Begin transaction
newComment := h.commentService.Create(comment, postId)
err := h.postService.IncreaseCount(postId)
if err != nil {
// rollback the transaction
// write some error message
}
// commit the transaction
writeJSON(w, http.StatusOK, newComment)
}
Your approach splitting services and repositories is a very good start. The following worked great for me:
contextAPI. Make all your methods in your services and repositories accept acontextas first parameter.Sessionthat would look like this:Sessionthat wrapspgxpool.BeginandTransactionshould inject a DB instance into thecontext.Context.pgxpoolfrom a context like so:repo.pool.Begin()as fallback). This way, the repository doesn't know (and doesn't have to know) if the operation is inside a transaction or not. The services can call multiple different repositories and multiple methods without worrying at all about the underlying mechanism. It also helps writing tests for your services without depending on your repositories or DB. This should handle nested transactions fine too.Sessionparameter to the constructor of your services that need them.Sessionwhen you need to execute multiple repository operations (that is to say: having a business transaction). For single operations, you can simply call the repository right away and it will use the database without a Tx thanks to the fallback.I used this approach with Gorm and not with pgx directly so this may not work as well because you need to work with
pgx.Txand not a single type*gorm.DB. I think this should work fine though and I hope this helped you going forward. Good luck!Complete example
Here is a more complete example based on the implementation I am using in my projects (using Gorm). In this example we have a user and a user action history. We want to create a "register" action record alongside the user record when the user registers.
I know you are not using Gorm but the logic stays the same, you will only have to implement your Session and repositories differently.
Session implementation
Service implementation
Repository implementation
Main (init)