- Install Scala and SBT
- To run tests, run
sbt test
- To run the application, run
sbt run
POST
to /accounts
with payload (example):
{
"firstName": "firstName",
"lastName": "lastName",
"balance": 30.0
}
HTTP 201 Created
with payload (example):
{
"id": "6ec33eed-a63f-4018-b94a-d3d7e0b42500"
}
GET
to /accounts/6ec33eed-a63f-4018-b94a-d3d7e0b42500
.
HTTP 200 OK
with payload (example):
{
"id": "6ec33eed-a63f-4018-b94a-d3d7e0b42500",
"firstName": "firstName",
"lastName": "lastName",
"balance": 30.0
}
OR
HTTP 404 Not Found
with payload:
{
"message": "Unable to find account with the specified ID"
}
PUT
to /accounts/6ec33eed-a63f-4018-b94a-d3d7e0b42500
with payload (example):
{
"firstName": "firstName2",
"lastName": "lastName2",
"balance": 40.0
}
HTTP 204 No Content
.
DELETE
to /accounts/6ec33eed-a63f-4018-b94a-d3d7e0b42500
.
HTTP 204 No Content
.
PUT
to /accounts/6ec33eed-a63f-4018-b94a-d3d7e0b42500
with payload (example):
{
"fromAccountId": "4353706e-8f3e-4b13-a9cd-b770a4e6ab0c",
"toAccountId": "a90ea363-be01-4ab6-a4d0-69b8b6se6ca79",
"amount": 10.0
}
HTTP 204 No Content
.
Errors are returned via an HTTP 5xx response with a JSON payload with message
and optional code
, for example:
{
"message": "Can't find account with ID a90ea363-be01-4ab6-a4d0-69b8b6se6ca79",
"code": "UnknownAccount"
}
The application is organised in 3 different layers listed bottom to top:
This layer is responsible for storing data and/or defining classes that interface with a persistent storage.
In this example everything is stored in memory in a TrieMap
to ensure thread-safety. Repositories don't contain any
business logic, all they do is query the storage (create, retrieve, update and delete).
This layer contains all the business logic and it interfaces with the repositories to manage the data. Sometimes this
layer simply proxies requests to the repositories but sometimes, like for the transferMoney
method, it can contain
logic that manipulates the data before hitting any repository. Services are not HTTP-specific and could be reused in any
other non-HTTP application.
This layer is very thin and is generally responsible for:
- Basic request validation
- Providing an anti-corruption layer between the API and the service layer. This allows to make changes to the business logic while maintaining the same interface
- Converting service responses into HTTP responses
- Marshalling all successful responses into JSON (or other formats)
- Handling exceptions and returning HTTP 5xx errors with JSON (or other formats) payload
- Other small things such as transforming a
None
response into an HTTP 404
- Verifying the request is authenticated and authorised (not in this example)
This pattern is used to do dependency injection.
Each component defines its dependencies through Scala's self-type annotations. For example:
trait DefaultAccountServiceComponent extends AccountServiceComponent {
self: AccountRepoComponent with ExecutionContextComponent with LoggingComponent =>
// ...
}
This code specifies that DefaultAccountServiceComponent
will need an implementation of AccountRepoComponent
,
ExecutionContextComponent
and LoggingComponent
. Note that these are trait
s so that the actual implementation can
be injected at a later point, making testing much easier.
All components are then put together at the top level:
class MoneyTransferService extends Actor
with AccountResource
with DefaultAccountServiceComponent
with InMemoryAccountRepoComponent
with ExecutionContextComponent
with LoggingComponent
with ActorRefFactoryComponent
with CommonExceptionHandler {
// ...
}
When unit testing a specific component, its dependencies can easily be defined/mocked:
trait MockEnvironment extends DefaultAccountServiceComponent with AccountRepoComponent with ExecutionContextComponent
with LoggingComponent with Mockito {
override val accountRepo = mock[AccountRepo]
override val executionContext = concurrentExecutionContext
override implicit val log = LoggingContext.NoLogging
// ...
}
Money is moved between accounts by decreasing the sender balance and increasing the recipient balance. This implies two separate queries to the account repository and any of them could be failing. Given that the updates are idempotent, the rollback strategy is simply to update both accounts to their initial value. The only case in which this logic will end up in an inconsistent state (i.e. money disappearing) is when the rollback (or part of it) fails too:
For example:
- Decrease sender balance ✓
- Increase recipient balance ✗
- Rollback and restore sender balance ✗
- Rollback and restore recipient balance ✓
In this case the sender will see his balance decreased without the recipient seeing the balance increased.
Given that the requirements said that the money is always transferred between internal accounts, the likelihood of some queries succeeding and some failing is very low. Anyway, to prevent this from happening there are various strategies:
- Use a transactional database to store data which can execute both queries in one go and guarantee consistency
- Implement a transaction manager, a separate entity that can handle rollbacks and take actions in case of failures
- Implement a 2-phase commit system