In this coding test, I have opted to use Golang as my preferred language.
- Time Allocation
- Project Structure
- Project Walk Through
- Architecture
- Project Design Considerations
- Reflection and Areas of Improvement
- Planning --- 40 mins
- Draft out starter code --- 2 hours
- Implementation --- 2 hours
- Testing and writing tests --- 3 hours
- Documentation --- 2 hours 30 min
*Time spent is not calculated in 1 seating, and is a very close approximation via stopwatch
├── README.md
├── TechnicalAssignment
├── cmd
├── db
└── server
├── main.go
└── pkg
├── constants
├── custError
├── service
└── utils
Explanation of packages:
- main.go: Main entry point for the application
- cmd: Contains packages that are used internally in this application
- db: Contains logic related to CRUD operations to the database, as well as DB files
- server: Contains blocking logic to listen for user input
- pkg: Contains packages with standalone logic
- constants: Contains all the constants in the project, from error messages to file paths
- custError: Custom errors that are used throughout the project
- service: Logic to handle individual user commands
- utils: Contains utility logic that can be reused throughout the package
-
Run
./TechnicalAssignment
. A list of command instructions and their description will be shown. The bottom left corner shows a username corresponding to the current user session, which is currentlyNIL
. -
Enter
register testreviewer password123
. This creates a new account with usernametestreviewer
and passwordpassword123
, subsequently logging into the newly created account. -
Enter
send admin 7
to send money to another account, in this caseadmin
. -
Enter
logout
to log out from the account. Notice the username shown is nowNIL
-
Enter
login admin password123
. The admin account is created upon project compilation and has special permissions. -
Enter
accounts
to keep track of the balances in all the accounts.
- Server listens to user input from
stdin
via afor
loop, inserver.ListenAndServe()
- User's input is parsed with
utils.ParseInput()
to determine the command - According to the parsed command, handlers in
service
would be called to service the user's request - The handlers might make CRUD operations to 3 database files,
username.txt
,balance.db
,password.db
A server implemented with a for-loop is used because I felt that the optimal user flow would be a synchronous exchange of instructions, where the user inputs a command, waits for a reply, before executing the next command. It is also the most straight-forward implementation I could think of, mimicking the behaviour of always-on hosted servers continuously listening on a connection.
User input is read from os.Stdin
and it is parsed with utils.ParseInput()
. Command parsed is then fed into a switch
case to be handled by the service
package.
I was debating between implementing a persistent datastore versus an in-memory datastore, because I felt that a persistent datastore would make the application too complicated. However, without a persistent datastore, the reviewer will have to keep registering
accounts to test the logic and thus I decided to use a simple key-value persistent datastore.
I used a public package github.com/rapidloop/skv
to implement the persistent datastore. It writes an encoded key-value pair a .db
file, namely balance.db
and password.db
. This skv
package only support get
and put
operations, and in order to get all usernames
in the Accounts()
handler, a plaintext file of username.txt
is used to store all the usernames.
In order to effectively emulate a wallet application where a unified interface serves all users, user authentication must be implemented. This is handled by the passsord.db
table and the Register()
and Login()
handlers.
The password.db
table stores a mapping of usernames and passwords that are created upon calling Register()
command. User authentication happens upon calling the Login()
command, which checks the password the user passed against those found in the password.db
table.
To effectively keep track of which user is operating the application, user sessions are used. A user session is a Wallet
struct that has a Username
field.
The sessionUser
is instantiated in main.go
and its reference is passed into server.ListenAndServe()
.
To log in in or log out, pointer to the sessionUser
is modified by assigning a username
or an empty struct respectively. If the Username
field is not set, GetUsername()
returns NIL
, which is displayed on the terminal display.
Once Username
is set, subsequent commands will be effective upon the binded Username
in the sessionUser(aka wallet)
pointer.
The simplest way for user to input instructions via terminal would be to enter a white-space separated string, according to instructions displayed upon starting the programme. Hence, utils.ParseInput()
is used to parse the input.
It does the following:
- return if input is an empty string
- splits input around each instance of a word with
string.Fields()
- de-capitalise input for standardisation
- returns (command, arguments)
- If command is
Register()
, register user and log in --- Logging in user when they register is a logical userflow. - If no user currently logged in
and
command is notLogin()
, prompt user to log in --- This is to ensure users login first and foremost before any commands. - Else, serve command normally for logged-in user
Here are the main considerations for handlers in service.go
:
- Take in pointer to current
sessionUser
for user details (wallet) - Take in
args
if necessary - Return any errors, whose messages are displayed to the user via
fmt.Println()
Register()
- Checks if
len(args) == 2
, to ensure username and password is entered - Calls
db.CreateUser
to persist information to DB
Login()
- Checks if
len(args) == 2
, to ensure username and password is entered - Queries
password.db
table to determine is username exists - If so, checks the password returned matches user input
- Logs user in
Deposit()
- Checks if
len(args) == 1
, to ensure deposit value is entered - Calls
topUp()
helper function (To be elaborated below)
Withdraw()
- Checks if
len(args) == 1
, to ensure withdraw value is entered - Calls
drawDown()
helper function (To be elaborated below)
Send()
- Checks if
len(args) == 2
, to ensure destination account and value is entered - Calls
drawDown()
on current user, andtopUp()
on destination account
Balance()
- Sets userSession pointer to point to an empty
Wallet
struct.
Accounts()
- Checks if account is
admin
account - Reads
username.txt
file to obtain list of usernames - Queries
balance.db
for each username and print it out
These couple of helper functions in the service
package exists to extract the repeated logic of:
- Adding or subtracting
x
amount from an account - Performing sanity checks --- e.g. Ensure no negative values, ensure funds > amount to be subtracted
This prevents repeated code, more specifically in the Send()
, Withdraw()
, and Deposit()
function, because sending money from A to B is essentially withdrawing money from A and depositing money to B.
pkg/constants
contains all constants that are used in the project. This provides a source of truth for all constants that are depended upon throughout the project, and one only needs to edit them in this file for the changes to be propagated throughout.
These are the main type of constants:
- Commands --- e.g.
"register"
,"withdraw"
- Error messages --- e.g.
"username already exists"
,account has insufficient funds
- FilePaths
pkg/custError
contains all the custom errors that are unique to this project. There are 3 benefits to creating custom errors:
- Custom errors can be re-used throughout the project, following the Don't Repeat Yourself (DRY) philosophy.
- Custom errors can have custom messages that are directly shown to users, and no extra parsing of errors need to be done on the server side.
- Custom errors make testing way easier and cleaner by providing an exhaustive list of negative outcomes that can happen.
Tests in this project follow 2 structures:
-
TestMain()
provides more high order functionality such a connecting to a test database and creating files before the tests, and deleting test database and files after the tests. -
TestExample()
is the meat of the testing logic, consisting of atestTable
slice of custom test objects. Each test object specifies the test name, required inputs, and desired outputs. A for loop is used to loop over the range oftestTable
slice, and calls a specific function to be tested (functionToBeTest
in this case), asserting that the input and output fields of eachtestTable
object (tt
) are equal.
There are 3 main areas that needs to be tested:
- Parsing of user input ---
utils.ParseInput()
- Individual handlers in
service.go
---Login()
,Register()
, etc - User flow --- e.g. User can only withdraw after logging in
parse_test.go()
- Ensure empty inputs are handled
- Ensure erratic spaces are handles --- e.g.
" login abc def "
service_test.go()
- Ensure all handlers are working fine with valid inputs
- Ensure all handlers output the accurate error for invalid inputs --- e.g. "$" sign in inputs, wrong number of arguments, negative inputs
- Ensure
register
does not create duplicate accounts - Ensure
login
only happens with correct credentials - Ensure
withdraw
andsend
does not happen when insufficient funds - Ensure
send
deducts from source account and credits destination account - Ensure
send
argument's destination account exists
user flow
User flow is tested manually.
Throughout the course of this task, I had 2 main goals:
- Keep the code/project as simple and clean as possible
- Make the user flow and experience as foolproof and bulletproof as possible
In terms of my first goal, I felt that I was off to a good start initially as I had taken a pretty long time to plan the overall concept of the application, as well as having a clear and differentiated project structure. However, the persistent data store added a bit of complexity to the project because I had to balance between creating a simple implementation and ensuring its persistence was reliable.
As a result, I am aware that the implementation of files as tables might not be a very clean solution compared to using a separate database like Postgres. However, I felt that I had navigated this trade-off to the best of my abilities.
Regarding the code cleanliness, I tried to extract repeated logic and constants out as much as possible, and attempted to provide clear comments in the code.
In terms of my second goal, I am confident that I have considered all cases of user flow, and handled all errors gracefully with concise error messages. I have spent the largest proportion of my time on testing - manual user flow testing and writing test. However, I feel that I could have included flow testing as integration tests, to ensure certain user flows are forbidden and handled.
Lastly, I hope that the user experience is as clear as possible, where all information shown or required is minimal and necessary, and users will not need much prompts to understand how to operate the application.