Thiht / jump-technical-test

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Jump Technical Test - Thibaut Rousseau

This project is an answer to the Technical Test from Join-Jump.com.

Quick Start

I used Taskfile as a task runner.

  • docker compose up -d: start and fill the database, and start the app on port :8080
  • task start: run the app in dev mode with live reload
  • task test: run the tests
  • task lint: lint the code

Warning

I used Go net/http as a router and it's a bit annoying regarding trailing slashes.

If you get Method Not Allowed when calling an endpoint, it's probably because of a missing trailing slash.

Listing Users

$ curl -L localhost:8080/users | jq
[
  {
    "user_id": 1,
    "first_name": "Bob",
    "last_name": "Loco",
    "balance": 2418.17
  },
  {
    "user_id": 2,
    "first_name": "Kevin",
    "last_name": "Findus",
    "balance": 492.97
  },
  {
    "user_id": 3,
    "first_name": "Lynne",
    "last_name": "Gwafranca",
    "balance": 825.4
  },
  {
    "user_id": 4,
    "first_name": "Art",
    "last_name": "Decco",
    "balance": 4027.58
  },
  {
    "user_id": 5,
    "first_name": "Lynne",
    "last_name": "Gwistic",
    "balance": 2267.77
  },
  {
    "user_id": 6,
    "first_name": "Polly",
    "last_name": "Ester Undawair",
    "balance": 1449.7
  },
  {
    "user_id": 7,
    "first_name": "Oscar",
    "last_name": "Nommanee",
    "balance": 2053.87
  },
  {
    "user_id": 8,
    "first_name": "Laura",
    "last_name": "Biding",
    "balance": 5200.6
  },
  {
    "user_id": 9,
    "first_name": "Laura",
    "last_name": "Norda",
    "balance": 5650.74
  },
  {
    "user_id": 10,
    "first_name": "Des",
    "last_name": "Ignayshun",
    "balance": 4361.8
  },
  {
    "user_id": 11,
    "first_name": "Mike",
    "last_name": "Rowe-Soft",
    "balance": 8183.13
  },
  {
    "user_id": 12,
    "first_name": "Anne",
    "last_name": "Kwayted",
    "balance": 1895.88
  },
  {
    "user_id": 13,
    "first_name": "Wayde",
    "last_name": "Thabalanz",
    "balance": 970.05
  },
  {
    "user_id": 14,
    "first_name": "Dee",
    "last_name": "Mandingboss",
    "balance": 2762.96
  },
  {
    "user_id": 15,
    "first_name": "Sly",
    "last_name": "Meedentalfloss",
    "balance": 9325.05
  },
  {
    "user_id": 16,
    "first_name": "Stanley",
    "last_name": "Knife",
    "balance": 5006.91
  },
  {
    "user_id": 17,
    "first_name": "Wynn",
    "last_name": "Dozeaplikayshun",
    "balance": 4783.33
  }
]

Creating Invoice

curl -Lv localhost:8080/invoice/ -H 'Content-Type: application/json' -d '{"user_id": 13, "amount": 113.45, "label": "Work for April"}'
< HTTP/1.1 204 No Content
Invalid user ID: curl -Lv localhost:8080/invoice/ -H 'Content-Type: application/json' -d '{"user_id": 21, "amount": 113.45, "label": "Work for April"}'
< HTTP/1.1 404 Not Found
{
  "error": "user from invoice doesn't exist"
}

Creating Transaction

curl -Lv localhost:8080/transaction/ -H 'Content-Type: application/json' -d '{"invoice_id": 1, "amount": 113.45, "reference": "JMPINV200220117"}'
< HTTP/1.1 204 No Content
Invoice already paid: curl -Lv localhost:8080/transaction/ -H 'Content-Type: application/json' -d '{"invoice_id": 1, "amount": 113.45, "reference": "JMPINV200220117"}'
< HTTP/1.1 422 Unprocessable Entity
{
  "error": "invoice is not pending"
}
Invalid invoice ID: curl -Lv localhost:8080/transaction/ -H 'Content-Type: application/json' -d '{"invoice_id": 42, "amount": 956.32, "reference": "JMPINV200220117"}'
< HTTP/1.1 404 Not Found
{
  "error": "invoice from transaction doesn't exist"
}
Invalid amount: curl -Lv localhost:8080/transaction/ -H 'Content-Type: application/json' -d '{"invoice_id": 1, "amount": 956.32, "reference": "JMPINV200220117"}'
< HTTP/1.1 400 Bad Request
{
  "error": "transaction amount does not match invoice amount"
}

Technical Stack

For the database I used Postgres which I'm most comfortable with. Another RDBMS like MariaDB/MySQL would have been fine too.

For the HTTP router, I used Go 1.22 standard net/http, because I wanted to try the routing enhancements from the latest version. These changes are relatively new in the Go ecosystem, so in a professional context I would probably have used Gin instead to benefit from the massive online resources available online.

For the logger, I used Go 1.21 standard log/slog to get structured logging without any external dependencies. It's also pretty new so in the real world I probably would have used zerolog which I have more experience with.

For the database, I used pgx with scany/pgxscan and not an ORM because I prefer to work with raw SQL when possible (easier to copy-paste to and from a db management tool).

Architecture

I followed a classic layered architecture, designed as follow:

  • handlers contains the HTTP handlers, and could possibly contain other kind of handlers (queue handlers, CLI handlers...). Their goal is to contain as little logic as possible (basically just basic input verification) and to call a service method directly. They're the only place in the app with knowledge of the caller (request/response writer).

  • stores contains the storage logic. They're the only place where a database or other persistence call should be made directly, and their public interface should remain abstract from the database. In theory, we should be able to replace the Postgres implementation with another RDBMS with no impact on the other layers, or add other storage systems (eg. Redis, S3, whatever...). The stores manipulate entities, the types as they exist in database.

  • services contains the business logic of the app. This layer makes the necessary calls to the stores and returns the type expected by a caller. To simplify type conversions, we should keep 1 handler = 1 service method, because this way we can be sure updating the return type of a service won't impact any API response. We could omit this restriction if we ensure the handlers are responsible for their return types, but it forces one more conversion layer.

This architecture could become an hexagonal/clean architecture by making dependency inversion between services and stores and creating adapters. In my opinion it's unnecessary for this kind of service as we have full control on the layers.

  • entities are the types representing the different tables
  • helpers contains a few pure methods that can be used to simplify redundant logic
  • pkg contains thin abstractions on the various infrastructure services used (just Postgres here, but it could also contain Kafka, Redis, ElasticSearch, or whatever is needed)
  • types contains the public types of the API: requests, responses, errors, pagination, etc. It's public because it could be used to create Go SDKs to make inter-services communication

Transactions

The last section requires using transactions to:

  • make sure we avoid data races on user.balance and invoice.status (eg. another service trying to update one of these fields)
  • make sure the database stays in a consistent state in case of error (eg. if we update the user balance successfully and then fail to update the invoice status, we need to rollback the changes to the balance)

To do this, I used:

  • the SELECT x FOR UPDATE feature of Postgres to lock the rows I'm working on
  • a "transactor" that I inject to the service, which provides a WithinTransaction function. This is a very efficient and easy to use pattern I came up when working at Crew (although a bit complex to setup)

Tests

I made mostly integration tests with a real database for the stores and services layers because they're the most reliable. I've found from experience that testing these layers with mocks has low value because it doesn't actually test the queries being made to the database.

The tests use database transactions to keep isolation between one another. This means the tests can create all the data they want because they're in their own transaction, so it doesn't impact other tests and get cleaned up after execution. Another approach I would want to try is database templating.

For simplicity I wrote the tests using the fixtures inserted by default in the test schema you provided, in real world the fixtures would be managed separately.

I made the choice of not testing handlers here because all the logic is in services and stores, handlers are only passing input and output data. It does make sense to test them mostly to avoid public facing breaking changes.

Things I would have done with more time

  • Better test coverage, at least 90% on services and stores (I just tested transaction_service to save some time, because it's the most critical component)
    • Also do some parallel testing on transaction_service to get better confidence on the concurrency safety
  • API testing to get contract based testing, based on Venom or Newman
  • CI via GitHub CI, I didn't enable it because I need to keep my quota for open source projects
  • Database schema migrations with a tool like tern
  • A metrics endpoint exposing Prometheus-like data (depending on the data aggregator used)
  • OpenAPI spec or shared Postman collection
  • Code coverage tracking

Things I would have done differently from the specs

GET /users endpoint

From experience, I wouldn't have returned a JSON array but a JSON object, such as:

{
    "data": [
        // users
    ]
}

This has the benefit of being more extensible and future proof in case we want to add some additional metadata (eg. pagination info)


I also wouldn't have returned the balance as a float because there's a risk of losing precision, and low control over the precision for display (we could end up displaying 10.3827384€ to a user which is not great).

Instead I would have returned the balance as an integer, and the frontend could display it as they see fit: rounding, localization (. vs , for decimal separator, € or $ sign before or after...).

POST /invoice

I would have named the endpoint as /invoices with an "s" to keep consistency between all endpoints.


For the same reasons as the balance above, I would have declared the balance as an integer instead of a float to avoid floating point issues. In this case it also forces to make a dangerous float to int conversion before inserting in DB, which can't be made reliably.


En cas de succès, la route doit retourner un code 204

204 implies "no content", so I would have instead returned 200 with the created entity in the response payload, with its ID. It's often easier for the front to get this data directly, so that they can redirect to the invoice page or something like this.

POST /transaction

I would have named the endpoint as /transaction with an "s" to keep consistency between all endpoints.


Same remark on the amount, I wouldn't have used a float in the payload.


I know that some payment systems use an append only method to manage balances, ie. instead of keeping a "balance" value somewhere, they keep a log of all the operations on the balance (withdraw 50, add 100) and compute the balance on the fly. I don't know if it's a better or the best solution but I know it comes with some advantages (easy to follow audit trail) and drawbacks (evergrowing data, maybe not a good fit for Postgres but better with a column db like Cassandra or Clickhouse), so in real life it would have been a real architecture decision to back with documentation and expertise (I know some guys who worked in payments so I would probably have asked them for feedback).

About


Languages

Language:Go 94.1%Language:PLpgSQL 4.5%Language:Dockerfile 1.4%