System Design: Build URL Shortening Service using Ktor (Part-2)
In this blog series, we are going to learn about the most common question asked in System design rounds. We will also implement the same using Ktor.
Welcome to part - 2 of the system design series of building your own URL Shortening Service. You can check Part: 1 here,
Till now, you have built your service and database. In this part, you will build,
- Exception handling
- Repository layer
- Domain layer
Let us start before any delay.
Setting up Exception Handling
If you remember you have Status
selected as a feature for this project. Status is responsible to handle any exception.
First, create a file called Status
in,
base -> Status
This will have an extension function configureStatusPages
and will look like,
fun Application.configureStatusPages() {
install(StatusPages) {
exception<NotFoundException> { cause ->
call.respond(ExceptionResponse(HttpStatusCode.NotFound, cause.message.toString()))
}
exception<SomethingWentWrongException> { cause ->
call.respond(ExceptionResponse(HttpStatusCode.InternalServerError, cause.message.toString()))
}
}
}
class NotFoundException(message: String?) : RuntimeException(message)
class SomethingWentWrongException(message: String = "Something went wrong!") : RuntimeException(message)
data class ExceptionResponse(val code: HttpStatusCode, val message: String? = null)
Here, create two custom Exception classes and the ExceptionResponse
class to return as output. Then install the StatusPages
and inside the block, which will handle both our custom exceptions.
This basically means if you throw any of these exceptions the above responses will be triggered of type ExceptionResponse.
Now, you have your StatusPages
successfully setup. As a next step, you need to create its provider
same as how you did for UrlService
in the following package,
base -> provider -> exception
In the exception package, create two files named ExceptionProvider
and ExceptionProviderImpl`.
The ExceptionProvider
interface looks like,
interface ExceptionProvider {
fun respondWithNotFoundException(message: String?): Exception
fun respondWithGenericException(message: String?): Exception
fun respondWithSomethingWentWrongException(): Exception
}
Lastly, the implementation class, ExceptionProviderImpl
will look like,
class ExceptionProviderImpl : ExceptionProvider {
override fun respondWithNotFoundException(message: String?): Exception {
return NotFoundException(message)
}
override fun respondWithGenericException(message: String?): Exception {
return Exception(message)
}
override fun respondWithSomethingWentWrongException(): Exception {
return SomethingWentWrongException()
}
}
ExceptionProvider will be responsible for throwing errors in the repository layer.
As a last step, you need to create is Locator
in di
package called ExceptionLocator
. This locator will look like,
object ExceptionLocator {
fun provideExceptionProvider(): ExceptionProvider {
return ExceptionProviderImpl()
}
}
You are done setting up everything you need for exception handling.
Now, let us setup our repository layer.
Setting up Repository Layer
First, create a repository package like,
feature -> url -> repository
Now, inside this as well you will create two files UrlRepository
and UrlRepositoryImpl
same as the service package.
Repository layer is like a link between Service and Domain layer.
UrlRepository
is an interface which looks like,
interface UrlRepository {
suspend fun createShortUrl(originUrl: String): BaseResponse<Any>
suspend fun findOriginalUrl(shortUrl: String): String?
suspend fun getTotalCount(shortUrl: String): BaseResponse<Any>
}
Here, you can see we have BaseResponse
as return type which is present in,
util -> Response
and it looks like,
interface BaseResponse<T : Any>
data class SuccessResponse<T : Any>(
val statusCode: HttpStatusCode,
val data: T? = null,
) : BaseResponse<T>
data class UnSuccessResponse<T : Any>(
val statusCode: HttpStatusCode,
val exception: T? = null,
) : BaseResponse<T>
Here, the two data classes are responsible for handling success and failure states. They both are of BaseResponse type.
These functions are responsible for having the execution and checks before hitting the database layer.
Now, implement the UrlRepository
to UrlRepositoryImpl
class like same as how you did with UrlService
interface.
UrlRepositoryImpl
class now looks like,
class UrlRepositoryImpl(
private val urlService: UrlService,
private val exceptionProvider: ExceptionProvider
) : UrlRepository {
override suspend fun createShortUrl(originUrl: String): BaseResponse<Any> {
val url = urlService.findShortUrl(originUrl)
return if (url != null) {
SuccessResponse(HttpStatusCode.Found, url)
} else {
val newShortUrl = urlService.createShortUrl(originUrl)
SuccessResponse(HttpStatusCode.Created, newShortUrl)
}
}
override suspend fun findOriginalUrl(shortUrl: String): String? {
return urlService.findOriginalUrl(shortUrl)
}
override suspend fun getTotalCount(shortUrl: String): BaseResponse<Any> {
val count = urlService.getTotalCount(shortUrl)
if (count != null) {
return SuccessResponse(HttpStatusCode.OK, count)
} else {
throw exceptionProvider.respondWithNotFoundException("Url not found!")
}
}
}
Here, you can see our class takes two parameters i.e. our UrlService
interface and ExceptionProvider
interface to follow the principle of passing the dependency from outside.
In the createShortUrl
, take the original/long URL and first check in the database if it's present or not using findShortUrl
.
If it's present you just return SuccessResponse
with Found
HTTP Code or create a new short URL of that original URL and return SuccessResponse
with Created
HTTP Code.
All of these functions are exposed via the UrlService
interface and as mentioned in the previous blog, you are not exposing the implementation of the Service but only the methods/functions.
In the getTotalCount
function, you want to check the total number of times the short URL was used by the users you can first check if the short URL is present or not. If it's present then do our operation as normal but when you don't find it, just throw an exception using the ExceptionProvider
interface.
The implementation of the UrlRepository is done. But something is left 🤭
Guessed it right!!! The DI part is left.
For that, first create a package in,
base -> provider -> repository
And create two files, RepositoryProvider
and RepositoryProviderImpl
.
The ReposityProvider will hold the method of our UrlRepository like,
interface RepositoryProvider {
fun provideUrlRepository(): UrlRepository
}
RepositoryProviderImpl
will now implement the RepositoryProvider
interface like,
class RepositoryProviderImpl(private val serviceLocator: ServiceLocator) : RepositoryProvider {
override fun provideUrlRepository(): UrlRepository {
}
}
If you look closely, you have ServiceLocator
as a constructor parameter for our RepositoryProviderImpl
class.
This Service locator will be responsible for passing the UrlServce
to the UrlRepositoryImpl
class from outside.
Now, the complete code looks like,
class RepositoryProviderImpl(private val serviceLocator: ServiceLocator) : RepositoryProvider {
override fun provideUrlRepository(): UrlRepository {
return RepositoryLocator.provideUrlRepository(
serviceLocator.provideUrlService()
)
}
}
Here, the repository implementation is coming from the RepositoryLocator
.
Now you need to create the RepositoryLocator
which will be present in,
di -> RepositoryLocator
The RepositoryLocator
looks like,
object RepositoryLocator {
fun provideUrlRepository(urlService: UrlService): UrlRepository {
return UrlRepositoryImpl(urlService,ExceptionLocator.provideExceptionProvider())
}
fun provideRepositoryProvider(): RepositoryProvider {
return RepositoryProviderImpl(ServiceLocator)
}
}
Here, provideUrlRepository
returns the instance of UrlRepository
and provideRepositoryProvider
returns the instance of RepositoryProvider
. These functions are responsible for providing the Repository wherever required.
Now, you have your Repository setup as well.
Lastly, the layer left is the domain layer. Once this is done, you can just plug the routes and things will start coming out nicely.
Setting up the domain layer.
Domain layer in any project is platform-independent and is only language-based.
In your case, the Domain layer is JVM-based and will be exposed to the routes and will be responsible for taking requests and responding as the response.
In the UrlRepository, you have 3 functions. So, for the domain layer, you will create the UseCases. But first, create a BaseUseCase
which will be extended by all the use cases.
Create a BaseUse case in,
base -> BaseUseCase
and it looks like,
/** A Use Case that takes an argument and returns a result. */
interface BaseUseCase<in I, R : Any> {
/** Executes this use case with given input. */
suspend operator fun invoke(input: I): BaseResponse<R>
}
This interface, will take an input and return an output or type BaseResponse. You are going to extend this to all the use cases where possible.
Now, as a next step create a package in,
feature -> url -> domain
And as the first UseCase, create a file CreateShortUrlUseCase
and extend it using BaseUseCase
. This use case will take a URL as an input and will return BaseResponse<Any>
.
Also, pass the UrlRepository
as the constructor parameter because they will be responsible for passing the methods to the UseCase.
The CreateShortUrlUseCase
looks like,
class CreateShortUrlUseCase(private val urlRepository: UrlRepository) : BaseUseCase<String, Any> {
/** Executes this use case with given input. */
override suspend fun invoke(input: String): BaseResponse<Any> {
return urlRepository.createShortUrl(input)
}
}
Here, the return statement is passing the createShortUrl
function from the repository as it was of BaseReponse<Any>
return type as well.
Similarly, create FindUrlHitCountUseCase
in the same package. The implementation you will see would be the same as the above with only the return function being changed from the repository.
FindUrlHitCountUseCase
looks like,
class FindUrlHitCountUseCase(private val urlRepository: UrlRepository):BaseUseCase<String,Any> {
/** Executes this use case with given input. */
override suspend fun invoke(input: String): BaseResponse<Any> {
return urlRepository.getTotalCount(input)
}
}
Now, as you have 3 functions in your repository and you created two use cases. You are left with one, i.e.
suspend fun findOriginalUrl(shortUrl: String): String?
You can see here, it doesn't have BaseResponse
as return type but String
as return value.
Now, create a use case for this that will be called FindShortUrlUseCase
.
This use case will not extend BaseUseCase
as the return type is not BaseResponse.
FindShortUrlUseCase
looks like,
class FindShortUrlUseCase(private val urlRepository: UrlRepository) {
/** Executes this use case with given input. */
suspend fun invoke(input: String): String? {
return urlRepository.findOriginalUrl(input)
}
}
You can see here, you don't have BaseUseCase implemented so this works as a normal class.
Now, we are done implementing all our use cases.
But one last thing is left. Think of it what is left 👻.
Correct!!! the provides and locator.
First, create a DomainProvider
and DomainProviderImpl
in,
base -> provider -> domain
The DomainProvider
will have all the provider functions of the use cases. You have three use cases in your project so, you would have 3 functions. DomainProvider
looks like,
interface DomainProvider {
fun provideCreateShortUrlUseCase(): CreateShortUrlUseCase
fun provideFindShortUrlUseCase(): FindShortUrlUseCase
fun provideFindUrlHitCountUseCase(): FindUrlHitCountUseCase
}
Now, you also need needs it implementation i.e. DomainProviderImpl
which looks like,
class DomainProviderImpl(private val repositoryProvider: RepositoryProvider) : DomainProvider {
override fun provideCreateShortUrlUseCase(): CreateShortUrlUseCase {
return CreateShortUrlUseCase(repositoryProvider.provideUrlRepository())
}
override fun provideFindShortUrlUseCase(): FindShortUrlUseCase {
return FindShortUrlUseCase(repositoryProvider.provideUrlRepository())
}
override fun provideFindUrlHitCountUseCase(): FindUrlHitCountUseCase {
return FindUrlHitCountUseCase(repositoryProvider.provideUrlRepository())
}
}
Here, the DomainProviderImpl
takes RepositoryProvider
as a constructor parameter and will be responsible for passing the repositories to the use cases instances like shown above.
Lastly, only the DomainLocator
is left which will be present in,
di -> DomainLocator
DomainLocator
will be exposing the DomainProvider
from a function but will have DomainProviderImpl
in the return value.
The DomainLocator
looks like,
object DomainLocator {
fun provideDomainProvider(): DomainProvider {
return DomainProviderImpl(
RepositoryLocator.provideRepositoryProvider()
)
}
}
Now we have all our Locator objects setup for Database, Domain, Service, Repository, and Exception.
This is all for the domain setup and this blog.
In part -3 of the series, we will setup our routing using locations and utilize the DomainLocator to provide the specific use cases to the routes.
If you want to read more about the mongo operations check this blog.
That is all for Part 2 of this blog. Click here for Part -3
If you like the series, do share it with people :)
Check the code here.
Thank you for reading :) Hope you learned something from my experience.
If you have anything more to discuss, I will be happy to get connected at hi_man_shoe.
See you in the next blog :)