Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions EXERCISE_DOC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Thought Process on Programming Exercise
=============
### Assumptions

I understood how Billie deals with e-commerce business and high level requirements from [here](README.md). However, I made few assumptions
while implementing the exercise instead of waiting for answers raised on weekend.
1. Requirement talks about _Merchant_ but the given code has _Organisation_ which somewhat similar but not completely same. **I assumed _Organisation_ as _Merchant_ domain for this exercise.**
2. _Order_ doesn't exist without _Organisation_ and _Shipment_ doesn't exist without _Order_.
3. The API requests are already validated from security point of view and authentication is implemented.
4. Designed data model is very basic and kept limited to the demo. No complex modeling, validation and business cases such as currency exchange etc. considered.

### What is done
1. With availability of very basic domain information, I created two domains _Order_ and _Shipment_ including respective DB tables.
2. Domains perform basic validations such as shipment notification validates existence of order and total amount within range of order amount.
3. Added integration tests which tests possible use-cases including request validations.
4. Separate domain and view models(Dto).

### Exercise vs Production scope
From resilience, scalability and performance point of view, I think the design has below basic amendments meet production level.
1. Multiple domains are deployed in single service, dividing them in separate microservices would be useful for scalability purpose.
2. Domains would have their own database which helps maintainability and limiting the scope to domain requirements.
4. When services communicate via REST end-points, it is possible to use contract testing to ensure neither server or client breaks the contact before moving to production.
5. Post end-points to be Idempotent to avoid multiple try from client doesn't result in new resource creation.
6. Optimize API timeouts, required retry strategy and circuit breaker to make services resilient.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.billie.organisations.orders.data

import io.billie.organisations.orders.domain.OrderId

/**
* Error condition representing non-existence of order
*/
class OrderNotFoundException(orderId: OrderId) : RuntimeException("No order found with id " + orderId.id)
162 changes: 162 additions & 0 deletions src/main/kotlin/io/billie/organisations/orders/data/OrderRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package io.billie.organisations.orders.data

import io.billie.organisations.orders.domain.*
import io.billie.organisations.viewmodel.Entity
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.ResultSetExtractor
import org.springframework.jdbc.support.GeneratedKeyHolder
import org.springframework.jdbc.support.KeyHolder
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.*

/**
* Offering service to query or create [Order]
*/
@Repository
class OrderRepository {

@Autowired
lateinit var jdbcTemplate: JdbcTemplate

fun findById(orderId: OrderId): Order? {
val orders = jdbcTemplate.query(orderQueryById(orderId), OrderExtractor())
return if (orders!!.isEmpty()) null else orders[0]
}

fun findByOrganisation(organisationId: Entity): List<Order> {
if(!validateOrganisation(organisationId)) {
throw OrganisationNotFoundException(organisationId)
}
return jdbcTemplate.query(orderQuery(organisationId), OrderExtractor())?: listOf()
}

fun create(order: Order): OrderId {
if(!validateOrganisation(order.organisationId)) {
throw OrganisationNotFoundException(order.organisationId)
}
val orderId = OrderId(createOrder(order))
order.shipments.stream().forEach { shipment -> addShipment(orderId, shipment)}
return orderId
}

fun update(order: Order): ShipmentId? {
val shipment = order.shipments.stream()
.filter { s -> s.shipmentId == null }
.findFirst()
return if (shipment.isPresent) ShipmentId(addShipment(order.orderId!!, shipment.get())) else null
}

private fun addShipment(orderId: OrderId, shipment: Shipment): UUID {
return createShipment(orderId, shipment)
}

private fun validateOrganisation(organisationId: Entity): Boolean {
val reply: Int? = jdbcTemplate.query(
{ connection ->
val ps = connection.prepareStatement(
"select count(1) from organisations_schema.organisations " +
"WHERE id = ? "
)
ps.setObject(1, organisationId.id)
ps
},
ResultSetExtractor {
it.next()
it.getInt(1)
}
)
return (reply != null) && (reply > 0)
}

private fun createOrder(order: Order): UUID {
val keyHolder: KeyHolder = GeneratedKeyHolder()
jdbcTemplate.update(
{ connection ->
val ps = connection.prepareStatement(
"INSERT INTO organisations_schema.orders (" +
"organisation_id, " +
"amount" +
") VALUES (?, ?)",
arrayOf("id")
)
ps.setObject(1, order.organisationId.id)
ps.setBigDecimal(2, order.orderAmount.amount)
ps
}, keyHolder
)
return keyHolder.getKeyAs(UUID::class.java)!!
}

private fun createShipment(orderId: OrderId, shipment: Shipment): UUID {
val keyHolder: KeyHolder = GeneratedKeyHolder()
jdbcTemplate.update(
{ connection ->
val ps = connection.prepareStatement(
"INSERT INTO organisations_schema.shipments (" +
"order_id, " +
"amount" +
") VALUES (?, ?)",
arrayOf("id")
)
ps.setObject(1, orderId.id)
ps.setBigDecimal(2, shipment.shipmentAmount.amount)
ps
}, keyHolder
)
return keyHolder.getKeyAs(UUID::class.java)!!
}

private fun orderQuery(organisationId: Entity) = "SELECT " +
"orders.id as order_id, " +
"orders.organisation_id as organisation_id, " +
"orders.amount as order_amount, " +
"shipments.id as shipment_id, " +
"shipments.amount as shipment_amount " +
"FROM " +
"organisations_schema.orders orders " +
"LEFT JOIN organisations_schema.shipments shipments ON orders.id = shipments.order_id " +
"WHERE orders.organisation_id = '" + organisationId.id.toString() + "'"

private fun orderQueryById(orderId: OrderId) = "SELECT " +
"orders.id as order_id, " +
"orders.organisation_id as organisation_id, " +
"orders.amount as order_amount, " +
"shipments.id as shipment_id, " +
"shipments.amount as shipment_amount " +
"FROM " +
"organisations_schema.orders orders " +
"LEFT JOIN organisations_schema.shipments shipments ON orders.id = shipments.order_id " +
"where orders.id = '" + orderId.id.toString() + "'"

class OrderExtractor : ResultSetExtractor<List<Order>> {
override fun extractData(rs: ResultSet): List<Order>? {

val orderMap = HashMap<OrderId, Order>()

while (rs.next()) {
var orderId = OrderId(rs.getObject("order_id", UUID::class.java))
var order = orderMap.get(orderId)
if (order == null) {
order = Order(
orderId,
Entity(rs.getObject("organisation_id", UUID::class.java)),
OrderAmount(rs.getBigDecimal("order_amount")),
mutableSetOf()
)
orderMap.put(orderId, order)
}
var shipmentId = rs.getObject("shipment_id", UUID::class.java)
if (shipmentId != null) {
order.addShipment(Shipment(
ShipmentId(shipmentId),
orderId,
ShipmentAmount(rs.getBigDecimal("shipment_amount"))
))
}
}
return orderMap.values.toList()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.billie.organisations.orders.data

import io.billie.organisations.viewmodel.Entity

/**
* Error condition representing non-existence of organisation
*/
class OrganisationNotFoundException(organisationId: Entity) : RuntimeException("No organisation found with id " + organisationId.id)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.billie.organisations.orders.data

import io.billie.organisations.orders.domain.OrderId
import io.billie.organisations.orders.domain.ShipmentAmount


/**
* Error condition representing total shipment amount exceeding order total amount
*/
class ShipmentAmountExceedOrderTotalException(shipmentAmount: ShipmentAmount, orderId: OrderId)
: RuntimeException("Shipments total ${shipmentAmount.amount} exceed order amount for order id ${orderId.id}")
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.billie.organisations.orders.data

import io.billie.organisations.orders.domain.OrderId
import io.billie.organisations.orders.domain.ShipmentAmount


/**
* Error condition representing shipment notification failure
*/
class ShipmentNotificationFailure(shipmentAmount: ShipmentAmount, orderId: OrderId)
: RuntimeException("Shipment notification for amount ${shipmentAmount.amount} failed for order id ${orderId.id}")
30 changes: 30 additions & 0 deletions src/main/kotlin/io/billie/organisations/orders/domain/Order.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.billie.organisations.orders.domain

import io.billie.organisations.orders.data.ShipmentAmountExceedOrderTotalException
import io.billie.organisations.viewmodel.Entity
import org.springframework.data.relational.core.mapping.Table
import java.math.BigDecimal

/**
* Domain object represents order for organisation
*/
@Table("ORDERS")
data class Order (
val orderId: OrderId?,
val organisationId: Entity,
val orderAmount: OrderAmount,
val shipments: MutableSet<Shipment> ) {

fun addShipment(shipment: Shipment) {
var existingShipmentTotal = BigDecimal.ZERO
for (existingShipment in shipments){
existingShipmentTotal = existingShipmentTotal.add(existingShipment.shipmentAmount.amount)
}

val shipmentTotal = existingShipmentTotal.add(shipment.shipmentAmount.amount)
if (shipmentTotal.compareTo(orderAmount.amount) > 0)
throw ShipmentAmountExceedOrderTotalException(ShipmentAmount(shipmentTotal), orderId!!)
Comment on lines +25 to +26
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order domain keeps track of the business rule where total amount of all shipments don't exceed order amount.


shipments.add(shipment)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.billie.organisations.orders.domain

import java.math.BigDecimal

/**
* Domain object represents order amount, should always be positive
*/
data class OrderAmount(val amount: BigDecimal) {
init {
require(amount.compareTo(BigDecimal.ZERO) > 0) { "Order amount must be positive" }
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order amount and also shipment amount domain objects ensure that it always receives positive value

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.billie.organisations.orders.domain

import java.util.*

/**
* Domain object represents UUID based order id
*/
data class OrderId(val id: UUID)
13 changes: 13 additions & 0 deletions src/main/kotlin/io/billie/organisations/orders/domain/Shipment.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.billie.organisations.orders.domain

import org.springframework.data.relational.core.mapping.Table

/**
* Domain object represents shipments for an [Order]
*/
@Table("SHIPMENTS")
data class Shipment(
val shipmentId: ShipmentId?,
val orderId: OrderId,
val shipmentAmount: ShipmentAmount
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.billie.organisations.orders.domain

import java.math.BigDecimal

/**
* Domain object represents shipment amount, should always be positive
*/
data class ShipmentAmount(val amount: BigDecimal) {
init {
require(amount.compareTo(BigDecimal.ZERO) > 0) { "Shipment amount must be positive" }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.billie.organisations.orders.domain

import java.util.*

/**
* Domain object represents UUID based shipment id
*/
data class ShipmentId(val id: UUID)
Loading