Skip to content

Sample Application implement Clean Architecture

Notifications You must be signed in to change notification settings

mirzaakhena/theitem

Repository files navigation

TheItem

This application developed using gogen framework code generator Gogen Framework

Gogen Framework Architecture

We applied many design concept in this project like

  • clean architecture (use case, input port, interactor, and output port)
  • dependency injection
  • non-anemic domain model
  • domain driven design (entity, value object, service, repository)
  • single responsibility principle
  • open closed principle
  • interface segregation principle

It also has a features like

  • use decoupling 3 layer architecture
  • integrating log each layer
  • config
  • error code collection
  • modifiable responses code
  • very clear separation of concern
  • fixed, consistent but still flexible code structure

For more detail about the feature you can read here

It has one entity domain_item/model/entity/item.go

type Item struct {
    ID              vo.ItemID     `json:"id" bson:"_id"`
    Created         time.Time     `json:"created"`
    Updated         time.Time     `json:"updated"`
    Name            string        `json:"name"`
    Rating          vo.Rating     `json:"rating" `
    Category        vo.Category   `json:"category"`
    ImageURL        vo.StringURL  `json:"image"`
    Reputation      vo.Reputation `json:"reputation"`
    ReputationBadge string        `json:"reputation_badge"`
    Price           int           `json:"price"`
    Availability    int           `json:"availability"`
}

A six use cases domain_item/usecase/

1. getallitem      --> Get All Item with filter
2. getoneitem      --> Get Only One Item by ID
3. runitemcreate   --> Create an Item
4. runitemdelete   --> Delete an Item by ID
5. runitempurchase --> Reduce the Availability of Item
6. runitemupdate   --> Update an Item

Each use case, published via REST API using gin-gonic domain_item/controller/restapi/router.go

runitemcreate   --> POST   /api/v1/items             
getallitem      --> GET    /api/v1/items             
getoneitem      --> GET    /api/v1/items/:item_id    
runitemupdate   --> PUT    /api/v1/items/:item_id    
runitemdelete   --> DELETE /api/v1/items/:item_id    
runitempurchase --> POST   /api/v1/items/:item_id/purchase 

Project Structure

domain_item
├── controller
│   ├── restapi
│   └── restapi2
├── gateway
│   ├── shared
│   ├── withmongodb
│   ├── withmysqldb
│   └── withsqlitedb
├── model
│   ├── entity
│   ├── errorenum
│   ├── repository
│   ├── service
│   └── vo
└── usecase
    ├── getallitem
    ├── getoneitem
    ├── runitemcreate
    ├── runitemdelete
    ├── runitempurchase
    └── runitemupdate

In this project demonstration, we have

  • 2 alternative controller (gin, echo)
  • 3 alternative gateway (sqlitem, mysql, mongodb)

Controller

you can choose to run this application with 2 alternative web framework.

  1. Gin (domain_item/controller/restapi)
  2. Echo (domain_item/controller/restapi)

See application/app_appitem.go

primaryDriver := restapi.NewController(appData, log, cfg)
//primaryDriver := restapi2.NewController(appData, log, cfg)

By default, we use Gin web framework

Gateway

you can decide to run this application with 3 alternative database

  1. SQLite using Gorm (domain_item/gateway/withmysqldb)
  2. MySQL using Gorm (domain_item/gateway/withsqlitedb)
  3. Native MongoDB (domain_item/gateway/withmongodb)

See application/app_appitem.go

datasource := withsqlitedb.NewGateway(log, appData, cfg)
//datasource := withmysqldb.NewGateway(log, appData, cfg)
//datasource := withmongodb.NewGateway(log, appData, cfg)

By default, it is running with SQLite db

Disclaimers!

in real life, it is very rare to have 3 alternative of database and/or 2 alternative web framework in one application . This is just for demonstration purposed

Run backend directly from code using SQLite

After git clone the code, open a terminal (we named it a 'first terminal'), then download the dependency by call this command

$ go mod tidy

Run it by

$ go run main.go appitem

then you will see the application is running

➜  theitem go run main.go appitem
Version 0.0.1
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /ping                           --> theitem/domain_item/controller/restapi.NewController.func1 (3 handlers)
[GIN-debug] GET    /web/*filepath                  --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (3 handlers)
[GIN-debug] HEAD   /web/*filepath                  --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (3 handlers)
[GIN-debug] POST   /api/v1/items                   --> theitem/domain_item/controller/restapi.(*controller).runItemCreateHandler.func1 (6 handlers)
[GIN-debug] GET    /api/v1/items                   --> theitem/domain_item/controller/restapi.(*controller).getAllItemHandler.func1 (6 handlers)
[GIN-debug] GET    /api/v1/items/:item_id          --> theitem/domain_item/controller/restapi.(*controller).getOneItemHandler.func1 (6 handlers)
[GIN-debug] PUT    /api/v1/items/:item_id          --> theitem/domain_item/controller/restapi.(*controller).runItemUpdateHandler.func1 (6 handlers)
[GIN-debug] DELETE /api/v1/items/:item_id          --> theitem/domain_item/controller/restapi.(*controller).runItemDeleteHandler.func1 (6 handlers)
[GIN-debug] POST   /api/v1/items/:item_id/purchase --> theitem/domain_item/controller/restapi.(*controller).runItemPurchaseHandler.func1 (6 handlers)
INFO  0000000000000000 server is running at :8080      restapi.(*gracefullyShutdown).Start:40

Now you can use postman or curl for accessing the API. But it is better to use the UI. Keep reading, because we also provide the UI.

Currently, the API is running on port 8080. You may change the port through config.json.

When running with SQLite db, we only use the db_name field in config.json and ignore the other database fields.

{
  "database": {
    "username": "root",
    "password": "12345",
    "host": "localhost",
    "port": "27017",
    "db_name": "itemdb"
  },
  "servers": {
    "appItem": {
      "address": ":8080"
    }
  }
}

Run frontend from code independently (development mode)

This application also has a simple user interface (UI) for testing purpose. The UI use all the capability of REST API. It built using vue js stack under web/ directory. To follow the further instruction of how to installing UI, make sure you already install nodejs in your system.

In order to run the UI, open new 'second terminal', change the directory

$ cd web

then install all the dependencies by running this command

$ npm install

While the backend apps is still running, run this command

$ npm run dev

Then you will see this output

➜  web npm run dev

> simulator@0.0.0 dev
> vite


  VITE v3.2.5  ready in 222 ms

  ➜  Local:   http://localhost:5173/web/
  ➜  Network: use --host to expose

Open your browser then access http://localhost:5173/web/

Run frontend via backend

You can run the frontend without running it separately from backend. In this case the backend will support the frontend as a webserver. All you need to do is build the web app into distribution package. Stop the frontend application in 'second terminal' (if frontend is still running), then run this command

$ npm run build

The command will create a bundled web application in folder web/dist/.

Back to 'first terminal', stop the backend by ctrl+c, then re-run it again

$ go run main.go appitem

Now, open your browser then access http://localhost:8080/web/. Notice that we are only running the backend apps without running the frontend apps.

Run backend directly from code using MySQL

Open file application/app_appitem.go then change the code, from this

datasource := withsqlitedb.NewGateway(log, appData, cfg)
//datasource := withmysqldb.NewGateway(log, appData, cfg)
//datasource := withmongodb.NewGateway(log, appData, cfg)

Into this

//datasource := withsqlitedb.NewGateway(log, appData, cfg)
datasource := withmysqldb.NewGateway(log, appData, cfg)
//datasource := withmongodb.NewGateway(log, appData, cfg)

Open config.json

{
  "database": {
    "username": "root",
    "password": "12345",
    "host": "localhost",
    "port": "3306",
    "db_name": "itemdb"
  },
  "servers": {
    "appItem": {
      "address": ":8080"
    }
  }
}

adjust the config as necessary (for example : username and password)

Run backend directly from code using MongoDB

Actually the process is same as using MySQL, we only switch the code in application/app_appitem.go into this

//datasource := withsqlitedb.NewGateway(log, appData, cfg)
//datasource := withmysqldb.NewGateway(log, appData, cfg)
datasource := withmongodb.NewGateway(log, appData, cfg)

And then adjust the config.json as necessary (please keep in mind by default, mongodb use port 27017)

Run with Docker

Before running with docker, first decide whether you want to run it by SQLite, MySQL or MongoDB by switching the implementation in application/app_appitem.go.

By default (NOT using docker), this application use config.json. In docker version, it uses different config which is in config.prod.json. You can change which config to use in docker-compose.yml file.

Currently, docker-compose.yml file specify 2 database image (mongodb and mysql) and 1 application image (myapp).

using Docker and SQLite

Since SQLite is a embedded database, we don't need to use any docker image. By default, it will just run simply by calling

$ docker compose up

using Docker and MySQL

You need to enable this part on docker-compose.yml

mysqldb:
    image: mysql
    restart: always
    environment:
        - MYSQL_ROOT_PASSWORD=12345
        - MYSQL_DATABASE=itemdb
    ports:
        - "3306:3306"

The config.prod.json for MySQL (pay attention to the host and port)

{
  "database": {
    "username": "root",
    "password": "12345",
    "host": "mysqldb",
    "port": "3306",
    "db_name": "itemdb"
  },
  "servers": {
    "appItem": {
      "address": ":8080"
    }
  }
}

using Docker and MongoDB

You need to enable this part on docker-compose.yml

mongodb:
    image: mongo
    ports:
        - "27017:27017"
    environment:
        - MONGO_INITDB_ROOT_USERNAME=root
        - MONGO_INITDB_ROOT_PASSWORD=12345

Adjust the config.prod.json (change the host and port)

{
  "database": {
    "username": "root",
    "password": "12345",
    "host": "mongodb",
    "port": "27017",
    "db_name": "itemdb"
  },
  "servers": {
    "appItem": {
      "address": ":8080"
    }
  }
}

Run the docker compose (you may add -d for running it in background)

$ docker compose up

Open browser then access

http://localhost:8080/web/

Sample Payload

Create new item

POST   /api/v1/items

REQUEST
{
    "name": "the first item",
    "rating": 2,
    "category":  "cartoon",
    "image": "http://image.aa",
    "reputation":  34,
    "price": 5000,
    "availability":  10
}

RESPONSE OK
{
  "success": true,
  "errorCode": "",
  "errorMessage": "",
  "data": {},
  "traceId": "KR9HW32UT28N0VKW"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0006",
  "errorMessage": "name length must greater than 10",
  "data": null,
  "traceId": "HO6ONN4SICA1UHTY"
}

RESPONSE FAIL
{
    "success": false,
    "errorCode": "ER0009",
    "errorMessage": "item with name 'the first item' already exist",
    "data": null,
    "traceId": "3Z1XZGHL1X14YYKD"
}

RESPONSE FAIL
{
    "success": false,
    "errorCode": "ER0005",
    "errorMessage": "word 'sex' is not allowed",
    "data": null,
    "traceId": "FKSAX0YK1MO6XS82"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0004",
  "errorMessage": "invalid rating value. must be integer between 0..5",
  "data": null,
  "traceId": "NT325V3DN8MLDC0A"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0003",
  "errorMessage": "invalid category. must be one of [photo sketch cartoon animation]",
  "data": null,
  "traceId": "NJXGNC72X60GLUBW"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0002",
  "errorMessage": "invalid url for 'image'",
  "data": null,
  "traceId": "91T18FT3SFFFLNVA"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0001",
  "errorMessage": "out of range reputation. must between 0 to 1000",
  "data": null,
  "traceId": "DS4YVNSPGCGHPRMF"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0011",
  "errorMessage": "price must greater or equal zero",
  "data": null,
  "traceId": "FXA7LLI93HOX9HFF"
}


Display all the item

GET    /api/v1/items
    page=1&
    size=2&
    rating=3&
    reputation_badge=yellow&
    availability_more=0&
    availability_less=100&
    category=photo

RESPONSE OK
{
  "success": true,
  "errorCode": "",
  "errorMessage": "",
  "data": {
    "count": 2,
    "items": [
      {
        "id": "0caf9621-aab4-4fc8-a133-47fc98ec36cf",
        "created": "2023-02-12T09:21:46.947388+07:00",
        "updated": "2023-02-12T09:21:46.947388+07:00",
        "name": "the first item",
        "rating": 2,
        "category": "animation",
        "image": "http://image.aa",
        "reputation": 34,
        "reputation_badge": "red",
        "price": 5000,
        "availability": 10
      },
      {
        "id": "de5aafdc-3361-4e69-83ea-5529b21f255e",
        "created": "2023-02-12T09:22:25.311257+07:00",
        "updated": "2023-02-12T09:22:25.311257+07:00",
        "name": "the second item",
        "rating": 2,
        "category": "cartoon",
        "image": "http://image.aa",
        "reputation": 34,
        "reputation_badge": "red",
        "price": 5000,
        "availability": 10
      }
    ]
  },
  "traceId": "K2VRLG7OC6AP5IGN"
}

Get one item by id

GET    /api/v1/items/0caf9621-aab4-4fc8-a133-47fc98ec36cf

RESPONSE
{
  "success": true,
  "errorCode": "",
  "errorMessage": "",
  "data": {
    "item": {
      "id": "0caf9621-aab4-4fc8-a133-47fc98ec36cf",
      "created": "2023-02-12T09:21:46.947388+07:00",
      "updated": "2023-02-12T09:21:46.947388+07:00",
      "name": "the first item",
      "rating": 2,
      "category": "animation",
      "image": "http://image.aa",
      "reputation": 34,
      "reputation_badge": "red",
      "price": 5000,
      "availability": 10
    }
  },
  "traceId": "1W9DAWJPNSFCWY38"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0007",
  "errorMessage": "unavailable item with id 'abcd9621-aab4-4fc8-a133-47fc98ec61de'",
  "data": null,
  "traceId": "EH863OJWJUGB2ERC"
}

Update the item

PUT    /api/v1/items/0caf9621-aab4-4fc8-a133-47fc98ec36cf

REQUEST
{
  "name": "the changes name",
  "category":"sketch",
  "image": "http://whatever.com",
  "price": 15000
}

RESPONSE
{
  "success": true,
  "errorCode": "",
  "errorMessage": "",
  "data": {},
  "traceId": "TB9HW79UT28I2V6P"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0006",
  "errorMessage": "name length must greater than 10",
  "data": null,
  "traceId": "HO6ONN4SICA1UHTY"
}

RESPONSE FAIL
{
    "success": false,
    "errorCode": "ER0009",
    "errorMessage": "item with name 'the first item' already exist",
    "data": null,
    "traceId": "47KXZPH5MX84YZZU"
}

RESPONSE FAIL
{
    "success": false,
    "errorCode": "ER0005",
    "errorMessage": "word 'sex' is not allowed",
    "data": null,
    "traceId": "FKSAX0YK1MO6XS82"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0003",
  "errorMessage": "invalid category. must be one of [photo sketch cartoon animation]",
  "data": null,
  "traceId": "NJXGNC72X60GLUBW"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0002",
  "errorMessage": "invalid url for 'image'",
  "data": null,
  "traceId": "91T18FT3SFFFLNVA"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0011",
  "errorMessage": "price must greater or equal zero",
  "data": null,
  "traceId": "FXA7LLI93HOX9HFF"
}

Delete the item

DELETE /api/v1/items/0caf9621-aab4-4fc8-a133-47fc98ec36cf

RESPONSE OK
{
  "success": true,
  "errorCode": "",
  "errorMessage": "",
  "data": {},
  "traceId": "AHPHIPWUNX147SPN"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0007",
  "errorMessage": "unavailable item with id 'abcd9621-aab4-4fc8-a133-47fc98ec61de'",
  "data": null,
  "traceId": "EH863OJWJUGB2ERC"
}

Purchase the Item

POST   /api/v1/items/de5aafdc-3361-4e69-83ea-5529b21f255e/purchase

REQUEST
{
  "quantity": 2
} 

RESPONSE OK
{
  "success": true,
  "errorCode": "",
  "errorMessage": "",
  "data": {},
  "traceId": "V1Z2A4IW93LH79CZ"
}

RESPONSE FAIL
{
  "success": false,
  "errorCode": "ER0008",
  "errorMessage": "unavailable item stock. requested 20 but availability is 10",
  "data": null,
  "traceId": "Q50VJPFV91UCL9Z1"
}

Error Codes

All error codes listed in domain_item/model/errorenum/error_codes.go

UnknownError                ER0000 unknown error
OutOfRangeReputation        ER0001 out of range reputation. must between 0 to 1000
InvalidURL                  ER0002 invalid url for '%s'
InvalidCategory             ER0003 invalid category. must be one of %v
InvalidRatingValue          ER0004 invalid rating value. must be integer between 0..5
ForbiddenWord               ER0005 word '%s' is not allowed
NameLengthMustGreaterThan   ER0006 name length must greater than %d
UnavailableItem             ER0007 unavailable item with id '%s'
UnavailableItemStock        ER0008 unavailable item stock. requested %d but availability is %d
ItemNameAlreadyExist        ER0009 item with name '%s' already exist
InvalidReputationBadge      ER0010 invalid reputation badge
PriceMustGreaterOrEqualZero ER0011 price must greater or equal zero

About

Sample Application implement Clean Architecture

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages