├── .editorconfig ├── AntiPattern.md ├── DDD.md ├── README.md ├── headers.md ├── k8s.md ├── sql.md └── vscode.md /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | -------------------------------------------------------------------------------- /AntiPattern.md: -------------------------------------------------------------------------------- 1 | # AntiPattern 2 | 3 | Acoshift's Go Project Guideline 4 | for High Productivity and Maintainable w/o Test 5 | 6 | > DO NOT use this guideline if you don't know what you're doing :P 7 | 8 | ## OS 9 | 10 | macOS ofc :D 11 | 12 | Use macOS srsly, all script will write to use w/ macOS. 13 | 14 | Another OS won't run the script and no one want to maintain script for all OSes. 15 | 16 | ## Editors 17 | 18 | - Recommend to use VSCode 19 | - VIM/NVIM if you know what you're doing. 20 | 21 | ### VSCode Plugins and Config 22 | 23 | [vscode.md](https://github.com/acoshift/go-project-guideline/blob/master/vscode.md) 24 | 25 | ## Softwares 26 | 27 | ```sh 28 | # git 29 | brew install git 30 | 31 | # Go 32 | brew install go 33 | # or brew install go --devel 34 | 35 | # dep 36 | brew install dep 37 | 38 | # live reload 39 | go get -u github.com/acoshift/goreload 40 | 41 | # Redis 42 | brew install redis 43 | brew services start redis 44 | 45 | # NodeJS 46 | brew install n 47 | n lts 48 | 49 | # Nodejs dependencies 50 | yarn global add node-sass gulp 51 | 52 | # --- backend --- 53 | 54 | # PostgreSQL (optional, normally use on cloud) 55 | brew install postgres 56 | brew services start postgres 57 | 58 | # PostgreSQL Client 59 | brew install pgcli 60 | 61 | # PostgreSQL GUI 62 | open https://eggerapps.at/postico/ 63 | 64 | # HTTP client 65 | open https://paw.cloud/ 66 | 67 | # jq (optional) 68 | brew install jq 69 | 70 | # --- devops --- 71 | 72 | # docker 73 | open https://store.docker.com/editions/community/docker-ce-desktop-mac 74 | 75 | # gcloud sdk https://cloud.google.com/sdk/ 76 | brew install python 77 | cd ~ 78 | curl -O https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-189.0.0-darwin-x86_64.tar.gz 79 | tar zxf google-cloud-sdk-189.0.0-darwin-x86_64.tar.gz 80 | rm google-cloud-sdk-189.0.0-darwin-x86_64.tar.gz 81 | ./google-cloud-sdk/install.sh 82 | source ~/.bash_profile 83 | gcloud init 84 | gcloud components install docker-credential-gcr gsutil beta 85 | 86 | # kubectl 87 | brew install kubectl 88 | ``` 89 | 90 | and run `brew update && brew upgrade` when you can. 91 | 92 | ## Project Structure 93 | 94 | ```bash 95 | $ tree . 96 | . 97 | |-- .gitignore 98 | |-- Dockerfile 99 | |-- Gopkg.lock 100 | |-- Gopkg.toml 101 | |-- Makefile 102 | |-- README.md 103 | |-- app 104 | |-- assets 105 | | `-- favicon.png 106 | |-- config 107 | |-- deploy.yaml 108 | |-- entity 109 | |-- main.go 110 | |-- migrate 111 | |-- node_modules 112 | |-- package.json 113 | |-- repository 114 | |-- service 115 | |-- style 116 | | |-- config.scss 117 | | |-- main.scss 118 | | `-- utils.scss 119 | |-- table.sql 120 | |-- template 121 | | |-- _components 122 | | |-- _layout 123 | | |-- admin 124 | | |-- app 125 | | |-- auth 126 | | `-- main.tmpl 127 | |-- vendor 128 | |-- Gulpfile.js 129 | `-- yarn.lock 130 | ``` 131 | 132 | > Only 1 .go file at root folder name main.go 133 | 134 | ### Gulpfile.js 135 | 136 | ```js 137 | const gulp = require('gulp') 138 | const concat = require('gulp-concat') 139 | const sass = require('gulp-sass') 140 | 141 | const sassOption = { 142 | outputStyle: 'compressed', 143 | includePaths: 'node_modules' 144 | } 145 | 146 | gulp.task('default', ['style']) 147 | 148 | gulp.task('style', () => gulp 149 | .src('./style/main.scss') 150 | .pipe(sass(sassOption).on('error', sass.logError)) 151 | .pipe(concat('style.css')) 152 | .pipe(gulp.dest('./assets')) 153 | ) 154 | ``` 155 | 156 | ### main.go 157 | 158 | - Load all configs using configfile from config folder 159 | - Init all vars ex. sql.DB, redis.Pool, google client 160 | 161 | ```go 162 | func main() { 163 | rand.Seed(time.Now().UnixNano()) 164 | 165 | config := configfile.NewReader("config") 166 | 167 | db, err := sql.Open("postgres", config.String("db")) 168 | if err != nil { 169 | log.Fatal(err) 170 | } 171 | 172 | app := app.New(&app.Config{ 173 | DB: db, 174 | SessionKey: config.Bytes("session_key"), 175 | SessionSecret: config.Bytes("session_secret"), 176 | SessionStore: redisStore.New(redisStore.Config{ 177 | Pool: &redis.Pool{ 178 | Dial: func() (redis.Conn, error) { 179 | return redis.Dial("tcp", config.String("session_host")) 180 | }, 181 | }, 182 | Prefix: config.String("session_prefix"), 183 | }), 184 | EmailDialer: gomail.NewPlainDialer( 185 | config.String("email_server"), 186 | config.Int("email_port"), 187 | config.String("email_user"), 188 | config.String("email_password"), 189 | ), 190 | EmailFrom: config.String("email_from"), 191 | ReCaptchaSite: config.String("recaptcha_site"), 192 | ReCaptchaSecret: config.String("recaptcha_secret"), 193 | }) 194 | 195 | err = hime.New(). 196 | TemplateDir("template"). 197 | TemplateRoot("root"). 198 | Minify(). 199 | Handler(app). 200 | GracefulShutdown(). 201 | ListenAndServe(":8080") 202 | if err != nil { 203 | log.Fatal(err) 204 | } 205 | } 206 | ``` 207 | 208 | ### config 209 | 210 | - Contains all configs 211 | - Each file store only 1 config (one line) 212 | 213 | ex. `config/session_host` 214 | `localhost:6379` 215 | 216 | config directory **MUST** equal to k8s ConfigMap 217 | 218 | #### for configmap 219 | 220 | ```yaml 221 | apiVersion: v1 222 | kind: ConfigMap 223 | metadata: 224 | name: project 225 | labels: 226 | app: project 227 | data: 228 | session_host: redis-1:6379 229 | session_prefix: 'project:' 230 | ``` 231 | 232 | ### table.sql 233 | 234 | - Contains the latest version of database schema 235 | 236 | ### migrate 237 | 238 | - Write migrate script if table schema changes 239 | - Include the first version of table.sql 240 | - Run though migrate will equals to run table.sql 241 | 242 | ### template 243 | 244 | - Contains all Go template 245 | - All layout **MUST** start with `_` 246 | 247 | ### entity 248 | 249 | - Contains entities for all use-cases 250 | 251 | ### repository 252 | 253 | - Contains functions to fetch data from database 254 | - One function can do only one thing (ex. call db 1 time) 255 | - All functions **MUST** **BE** stateless 256 | 257 | ### app 258 | 259 | - Contains all handlers, template funcs 260 | - Use global vars here 261 | 262 | ### service 263 | 264 | - Contains complex logics (call repository multiple time in tx) 265 | 266 | > always use pgsql.RunInTx if write to database multiple time 267 | > 268 | > DO NOT do long operation inside tx ex. verify password 269 | 270 | - All functions in service **MUST** **BE** stateless 271 | 272 | ### vendor 273 | 274 | - Always push vendor to git 275 | - And use prune config below 276 | 277 | ```toml 278 | [prune] 279 | non-go = true 280 | go-tests = true 281 | unused-packages = true 282 | ``` 283 | 284 | ### assets 285 | 286 | - Try not to store anything in assets 287 | 288 | - Use Google Storage to store assets, and set max-age to maximum with hash filename 289 | 290 | ## Workflow 291 | 292 | - Everyone work on master (YES!!! on MASTER) 293 | - Only push working code, DO NOT push any broken code, other peep can not work 294 | - Always use `git pull -r` when can not (or before) push 295 | - For new feature that will break current feature, use another branch 296 | - Write good code at the start, no one will review your code 297 | - Trust teammate code 298 | 299 | ## Testing 300 | 301 | - DO NOT write test, you have 4294967295-1 things to do 302 | - No one going to maintain your test, include yourself 303 | - Test won't guarantee that it won't break, your code is 304 | - Requirement will 100% change anyway 305 | 306 | ## Deployment 307 | 308 | - Check any database schema changes, if it break wait until 2 AM and scale down deployment to 0 309 | - Build inside our computer 310 | - Push to gcr.io 311 | - Set new image to k8s 312 | - Always use commit sha to tag docker image 313 | 314 | ### k8s checklist 315 | 316 | - Create new ingress (l7 load balancer) 317 | - Fix domain for l7 ingress 318 | - Use isolate secret to store tls in nginx-ingress 319 | - Setup ingress's rate limit annotation for your use-case 320 | - Create configmap 321 | - Set resource requests 322 | - Set revision history to 3 323 | - Set replicas to number of node - 1 324 | - Create deployment 325 | - Create pod disruption budget 326 | 327 | ## Go Guideline 328 | 329 | ### Imports 330 | 331 | Order imports by 332 | 333 | - Native 334 | - Lib 335 | - Project 336 | 337 | ```go 338 | import ( 339 | "database/sql" 340 | "encoding/base64" 341 | "html/template" 342 | "io/ioutil" 343 | "math/rand" 344 | "net/http" 345 | "time" 346 | 347 | "github.com/acoshift/header" 348 | "github.com/acoshift/hime" 349 | "github.com/acoshift/httprouter" 350 | "github.com/acoshift/middleware" 351 | "github.com/acoshift/pgsql" 352 | "github.com/acoshift/session" 353 | "github.com/acoshift/webstatic" 354 | "github.com/dustin/go-humanize" 355 | "github.com/shopspring/decimal" 356 | "github.com/skip2/go-qrcode" 357 | "gopkg.in/gomail.v2" 358 | "gopkg.in/yaml.v2" 359 | 360 | "github.com/acoshift/project/entity" 361 | "github.com/acoshift/project/repository" 362 | ) 363 | ``` 364 | 365 | ### Comment 366 | 367 | - Write comment for all **export** 368 | 369 | ```go 370 | // Config is the app's config 371 | type Config struct { 372 | DB *sql.DB 373 | SessionStore session.Store 374 | SessionKey []byte 375 | SessionSecret []byte 376 | EmailDialer *gomail.Dialer 377 | EmailFrom string 378 | ReCaptchaSite string 379 | ReCaptchaSecret string 380 | } 381 | ``` 382 | 383 | ```go 384 | // ListAdminUsers lists all users for admin view 385 | func ListAdminUsers(q Queryer, limit, offset int64) ([]*entity.AdminUser, error) { 386 | ... 387 | } 388 | ``` 389 | 390 | - Write comments for complex logic **before** write code 391 | 392 | ```go 393 | // Transfer transfers money from src user to dst user 394 | func Transfer(db *sql.DB, srcUserID, dstUserID string, currency string, amount decimal.Decimal) error { 395 | return pgsql.RunInTx(db, nil, func(tx *sql.Tx) error { 396 | // get src user's balance 397 | 398 | // verify src balance with amount 399 | 400 | // withdraw balance from src user 401 | 402 | // deposit balance to dst user 403 | 404 | return nil 405 | }) 406 | } 407 | ``` 408 | 409 | ### Entity 410 | 411 | - Split entity for each use-case 412 | 413 | ```go 414 | // User is the current user 415 | type User struct { 416 | ... 417 | } 418 | 419 | // AdminUser is the user for admin view 420 | type AdminUser struct { 421 | ... 422 | } 423 | ``` 424 | 425 | ### Repository 426 | 427 | - DO NOT use `id`, use userID, txID, etc. 428 | 429 | - For struct, always returns pointer to struct `*entity.Type` 430 | 431 | - For slice, always returns slice of pointer to struct `[]*entity.Type` 432 | 433 | - For non-nil error, pointer **MUST** **NOT** nil, include slice (use zero length slice) 434 | 435 | - Use plural for slice, ex. Users, Tokens 436 | 437 | - Always use `int64` for limit, offset, count 438 | 439 | - `Get` for get data from ID 440 | 441 | ```go 442 | func GetUser(q Queryer, userID string) (*entity.User, error) { 443 | ... 444 | } 445 | 446 | func GetUsername(q Queryer, userID string) (username string, err error) { 447 | ... 448 | } 449 | ``` 450 | 451 | - `Find` for find from filters 452 | 453 | ```go 454 | func FindPendingWithdrawTxs(q Queryer, limit, offset int64) ([]*entity.WithdrawTx, error) { 455 | ... 456 | } 457 | ``` 458 | 459 | - `List` for list entities without filter 460 | 461 | ```go 462 | func ListUsers(q Queryer, limit, offset int64) ([]*entity.User, error) { 463 | ... 464 | } 465 | ``` 466 | 467 | - `Get` for aggregate data 468 | 469 | ```go 470 | func GetUserCount(q Queryer) (cnt int64, err error) { 471 | ... 472 | } 473 | 474 | func GetUserBalance(q Queryer, userID string) (balance decimal.Decimal, err error) { 475 | ... 476 | } 477 | 478 | func GetUserTotalDeposit(q Queryer, userID string, currency string) (amount decimal.Decimal, err error) { 479 | ... 480 | } 481 | ``` 482 | 483 | - `Set` for set data 484 | 485 | ```go 486 | func SetUserPassword(q Queryer, userID string, hashedPassword string) error { 487 | ... 488 | } 489 | ``` 490 | 491 | - `Remove` to delete data 492 | 493 | ```go 494 | func RemoveToken(q Queryer, tokenID string) error { 495 | ... 496 | } 497 | 498 | func RemoveUserTokens(q Queryer, userID string) error { 499 | ... 500 | } 501 | 502 | func RemoveTokensCreatedBefore(q Queryer, before time.Time) error { 503 | ... 504 | } 505 | ``` 506 | 507 | - `Create` for insert data 508 | 509 | ### Repository function templates 510 | 511 | - Select multiple rows 512 | 513 | ```go 514 | func ListUsers(q Queryer, limit, offset int64) ([]*entity.User, error) { 515 | // always use lower case SQL 516 | rows, err := q.Query(` 517 | select 518 | id, username, email, 519 | created_at, updated_at 520 | from users 521 | order by created_at desc 522 | offset $1 limit $2 523 | `, offset, limit) 524 | if err != nil { 525 | return nil, err 526 | } 527 | defer rows.Close() 528 | 529 | xs := make([]*entity.User, 0) 530 | for rows.Next() { 531 | var x entity.User 532 | err = rows.Scan( 533 | &x.ID, &x.Username, &x.Email, // use line-break like SQL above 534 | &x.CreatedAt, &x.UpdatedAt, 535 | ) 536 | if err != nil { 537 | return nil, err 538 | } 539 | xs = append(xs, &x) 540 | } 541 | 542 | if err = rows.Err(); err != nil { 543 | return nil, err 544 | } 545 | if err = rows.Close(); err != nil { 546 | return nil, err 547 | } 548 | 549 | return xs, nil 550 | } 551 | ``` 552 | 553 | - Select one row 554 | 555 | ```go 556 | func GetUser(q Queryer, userID string) (*entity.User, error) { 557 | var x entity.User 558 | err := q.Query(` 559 | select 560 | id, username, email, 561 | created_at, updated_at 562 | from users 563 | where id = $1 564 | `, userID).Scan( // use line-break like SQL above 565 | &x.ID, &x.Username, &x.Email, 566 | &x.CreatedAt, &x.UpdatedAt, 567 | ) 568 | if err != nil { 569 | return nil, err 570 | } 571 | return &x, nil 572 | } 573 | ``` 574 | 575 | - Select w/ join (always use `left join` if possible), for `on` use joined table on the left 576 | 577 | ```go 578 | func ListAdminTxs(q Queryer, limit, offset int64) ([]*entity.AdminTx, error) { 579 | rows, err := q.Query(` 580 | select 581 | t.id, u.username, t.currency, t.amount, 582 | t.type, t.created_at 583 | from transactions as t 584 | left join users as u on u.id = t.user_id 585 | order by t.created_at desc 586 | offset $1 limit $2 587 | `, offset, limit) 588 | if err != nil { 589 | return nil, err 590 | } 591 | defer rows.Close() 592 | 593 | xs := make([]*entity.AdminTx, 0) 594 | for rows.Next() { 595 | var x entity.AdminTx 596 | err = rows.Scan( 597 | &x.ID, &x.Username, &x.Currency, &x.Amount, 598 | &x.Type, &x.CreatedAt, 599 | ) 600 | if err != nil { 601 | return nil, err 602 | } 603 | xs = append(xs, &x) 604 | } 605 | 606 | if err = rows.Err(); err != nil { 607 | return nil, err 608 | } 609 | if err = rows.Close(); err != nil { 610 | return nil, err 611 | } 612 | 613 | return xs, nil 614 | } 615 | ``` 616 | 617 | - Insert, create model if params too long 618 | 619 | ```go 620 | // CreateUserModel is the model for CreateUser 621 | type CreateUserModel struct { 622 | Username string 623 | Email string 624 | HashedPassword string 625 | ReferrerID string 626 | } 627 | 628 | // CreateUser creates new user 629 | func CreateUser(q Queryer, x *CreateUserModel) (userID string, err error) { 630 | err = q.QueryRow(` 631 | insert into users 632 | ( 633 | username, email, password, 634 | referrer 635 | ) 636 | values 637 | ($1, $2, $3, $4) 638 | returning id 639 | `, 640 | x.Username, x.Email, x.HashedPassword, 641 | sql.NullString{String: x.ReferrerID, Valid: x.ReferrerID != ""}, 642 | ).Scan(&userID) 643 | return 644 | } 645 | ``` 646 | 647 | - Update, use where for first placeholder 648 | 649 | ```go 650 | func SetUserPassword(q Queryer, userID string, hashedPassword string) error { 651 | _, err := q.Exec(` 652 | update users 653 | set 654 | password = $2 655 | updated_at = now() 656 | where id = $1 657 | `, userID, hashedPassword) 658 | return err 659 | } 660 | ``` 661 | 662 | ### Handler 663 | 664 | - Panic when unexpected error, that **CAN** **NOT** continue (fatal error for handler scoped) 665 | 666 | ```go 667 | func must(err error) { 668 | if err != nil { 669 | panic(err) 670 | } 671 | } 672 | 673 | func profileGetHandler(ctx hime.Context) hime.Result { 674 | user := getUser(ctx) 675 | 676 | config, err := repository.GetUserConfig(db, user.ID) 677 | must(err) 678 | 679 | ... 680 | } 681 | ``` 682 | 683 | - Use [hime](https://github.com/acoshift/hime) for handler standard 684 | 685 | - Always have health check at `:18080/{readiness,liveness}` 686 | 687 | ```go 688 | probe := probehandler.New() 689 | health := http.NewServeMux() 690 | health.Handle("/readiness", probe) 691 | health.Handle("/liveness", probehandler.Success()) 692 | 693 | go http.ListenAndServe(":18080", health) 694 | 695 | err = hime.New(). 696 | // ... 697 | GracefulShutdown(). 698 | Notify(probe.Fail). 699 | Wait(5 * time.Second). 700 | ListenAndServe(":8080") 701 | ``` 702 | 703 | ### Security Middlewares 704 | 705 | - Reject CORS 706 | 707 | ```go 708 | func noCORS(h http.Handler) http.Handler { 709 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 710 | if r.Method == http.MethodOptions { 711 | http.Error(w, "Forbidden", http.StatusForbidden) 712 | return 713 | } 714 | h.ServeHTTP(w, r) 715 | }) 716 | } 717 | ``` 718 | 719 | - Add security headers [securityheaders.io](https://securityheaders.io/) 720 | 721 | ```go 722 | func securityHeaders(h http.Handler) http.Handler { 723 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 724 | w.Header().Set(header.XFrameOptions, "deny") 725 | w.Header().Set(header.XXSSProtection, "1; mode=block") 726 | w.Header().Set(header.XContentTypeOptions, "nosniff") 727 | h.ServeHTTP(w, r) 728 | }) 729 | } 730 | ``` 731 | 732 | - Filter **unused** methods 733 | 734 | ```go 735 | func methodFilter(h http.Handler) http.Handler { 736 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 737 | switch r.Method { 738 | case http.MethodGet, http.MethodHead, http.MethodPost: 739 | h.ServeHTTP(w, r) 740 | default: 741 | http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) 742 | } 743 | }) 744 | } 745 | ``` 746 | 747 | - Protect CSRF w/ origin and referer (disable when development) [acoshift/csrf](https://github.com/acoshift/csrf) 748 | 749 | ```go 750 | csrf.New(csrf.Config{ 751 | Origins: []string{"https://yourwebsite"}, 752 | }), 753 | ``` 754 | 755 | ## Style 756 | 757 | - Add hash to file name 758 | 759 | - Use Google Storage to store all style versions 760 | 761 | - Always set max-age to maximum (31536000) 762 | 763 | - Always use `-Z` when copy using gsutil, if directly use from Google Storage 764 | 765 | > See Makefile section for more detail 766 | 767 | ## Makefile 768 | 769 | ```makefile 770 | PROJECT=YOUR GCP PROJECT 771 | IMAGE=YOUR DOCKER IMAGE REPOSITORY 772 | ENTRYPOINT=YOUR COMPILED FILE NAME 773 | BUCKET=YOUR BUCKET NAME 774 | DEPLOYMENT=YOUR K8S DEPLOYMENT NAME 775 | CONTAINER=YOUR K8S CONTAINER NAME 776 | 777 | COMMIT_SHA=$(shell git rev-parse HEAD) 778 | GO=go 779 | 780 | dev: 781 | goreload -x vendor --all 782 | 783 | clean: 784 | rm -f $(ENTRYPOINT) 785 | 786 | style: 787 | gulp style 788 | 789 | deploy-style: 790 | yarn install 791 | mkdir -p .build 792 | node-sass --output-style compressed --include-path node_modules style/main.scss > .build/style.css 793 | $(eval style := style.$(shell cat .build/style.css | md5).css) 794 | mv .build/style.css .build/$(style) 795 | gsutil -h "Cache-Control:public, max-age=31536000" cp -Z .build/$(style) gs://$(BUCKET)/$(style) 796 | echo "style.css: https://storage.googleapis.com/$(BUCKET)/$(style)" > .build/static.yaml 797 | 798 | build: clean 799 | env GOOS=linux GOARCH=amd64 CGO_ENABLED=0 $(GO) build -o $(ENTRYPOINT) -ldflags '-w -s' main.go 800 | 801 | docker: build deploy-style 802 | docker build -t $(IMAGE):$(COMMIT_SHA) . 803 | docker push $(IMAGE):$(COMMIT_SHA) 804 | 805 | patch: 806 | kubectl set image deploy/$(DEPLOYMENT) $(CONTAINER)=$(IMAGE):$(COMMIT_SHA) 807 | 808 | deploy: docker patch 809 | 810 | rollback: 811 | kubectl rollout undo deploy/$(DEPLOYMENT) 812 | ``` 813 | 814 | ## .gitignore 815 | 816 | ```.gitignore 817 | .DS_Store 818 | .goreload 819 | private/ 820 | node_modules/ 821 | .build/ 822 | /assets/style.css 823 | /projectname # compiled file name 824 | ``` 825 | 826 | ## Dockerfile 827 | 828 | ```Dockerfile 829 | FROM acoshift/go-scratch 830 | 831 | ADD ENTRYPOINT / # your entrypoint 832 | COPY template /template 833 | COPY assets /assets 834 | COPY .build/static.yaml /static.yaml 835 | EXPOSE 8080 836 | 837 | ENTRYPOINT ["/ENTRYPOINT"] # your entrypoint 838 | ``` 839 | 840 | ## Finally 841 | 842 | Programming, Motherfucker! 843 | -------------------------------------------------------------------------------- /DDD.md: -------------------------------------------------------------------------------- 1 | # Domain Driven Development (DDD) 2 | 3 | Acoshift's Go Project Guideline for Low Productivity but Maintainable w/ Test 4 | 5 | > Recommend for use this guileline for large project. 6 | > 7 | > DO NOT use in small project, you will end up write a lot of boilerplate code 8 | 9 | ## Project Structure 10 | 11 | ``` 12 | . 13 | |-- .gitignore 14 | |-- Dockerfile 15 | |-- Makefile 16 | |-- README.md 17 | |-- domain1 18 | | |-- service 19 | | | |-- subdomain1.go # service interface for subdomain1 20 | | | |-- subdomain2.go # service interface for subdomain2 21 | | |-- subdomain1 22 | | |-- subdomain2 23 | | |-- transport1 24 | | | |-- subdomain1 25 | | | | |-- endpoint.go 26 | | | | |-- transport.go 27 | | | |-- subdomain2 28 | | |-- transport.go 29 | |-- domain2 30 | | |-- service.go # service interface and implement for domain2 31 | | |-- domain2.go # entities, and repository interface for domain2 32 | | |-- transport.go # mount service layer into transport layer 33 | | |-- endpoint.go # wrap service action with transport-specific handler 34 | |-- domain3 # shared domain 35 | | |-- domain3.go # entities, and repository interface for domain3 36 | |-- domain4 37 | |-- storage1 38 | | |-- domain2.go # implement domain2 repository for storage1 39 | | |-- domain3.go # implement domain3 repository for storage1 40 | |-- storage2 41 | | |-- domain4.go # implement domain4 repository for storage2 42 | |-- api # shared global data types about api ex. errors 43 | | |-- errors.go # global errors 44 | |-- main.go 45 | |-- table.sql 46 | |-- migrate 47 | | |-- 0000-init-database.sql 48 | | |-- 0001-add-user-status.sql 49 | |-- data # shared data 50 | | |-- thai_provinces.json 51 | |-- internal # shared logic, general data types ex. custom datetime type 52 | | |-- internal1 53 | | | |-- internal1.go 54 | | |-- httptransport 55 | | | |-- httptransport.go # http transport layer shared functions 56 | | | |-- middleware.go # http transport related middlewares 57 | | |-- websockettransport 58 | | | |-- websockettransport.go # websocket transport layer shared functions 59 | | | |-- middleware.go # websocket transport related middlewares 60 | | |-- grpctransport 61 | | | |-- grpctransport.go # grpc transport layer shared functions 62 | | | |-- middleware.go # grpc transport related middlewares 63 | | |-- sqldb 64 | | | |-- db.go # interface for sql.DB, sql.Tx 65 | |-- config 66 | | |-- config1 67 | | |-- config2 68 | |-- assets # for web assets 69 | | |-- landing # landing assets 70 | | |-- webadmin # webadmin assets 71 | |-- landing # landing page 72 | | |-- service 73 | | | |-- landing.go # landing service interface 74 | | |-- service.go # implement for landing service 75 | | |-- handler.go # http handler/controller factory 76 | | |-- index.go # index related handler 77 | | |-- faq.go # faq related handler 78 | |-- webadmin # web admin 79 | |-- template # html template 80 | | |-- landing # landing's templates 81 | | | |-- main.tmpl 82 | | | |-- _layout 83 | | |-- webadmin # web admin's templates 84 | |-- mock # mock repositories 85 | | |-- user.go # mock user repository 86 | ``` 87 | ## Domains 88 | 89 | Domain is the thing that you interest. 90 | 91 | For example 92 | 93 | - `auth` is a domain for authentication. `auth` domain can **import** other **shared** **domain** 94 | - `profile` is a domain for current user's profile. 95 | 96 | ```go 97 | // profile/service.go 98 | 99 | package profile 100 | 101 | // Service is the profile service 102 | type Service interface { 103 | // Get returns user's profile 104 | Get(userID string) (*Profile, error) 105 | 106 | // Update updates user's profile 107 | Update(profile *Profile) error 108 | 109 | // ChangePhoto changes user profile's photo 110 | ChangePhoto(userID string, r io.Reader) error 111 | } 112 | ``` 113 | 114 | for upload user's photo, file repository is required to do this. 115 | 116 | ```go 117 | // file/file.go 118 | 119 | package file 120 | 121 | // Repository is the file storage 122 | typs Repository interface { 123 | // Store stores data in r and return its download url 124 | Store(r io.Reader, baseDir string) (downloadURL string, err error) 125 | } 126 | ``` 127 | 128 | for get, update data for user, user repository is required 129 | 130 | ```go 131 | // user/user.go 132 | 133 | package user 134 | 135 | // Repository is the user storage 136 | type Repository interface { 137 | // CreateUser creates new user and return created id 138 | CreateUser(db sqldb.DB, user *User) (userID string, err error) 139 | 140 | // GetUser gets user by id 141 | GetUser(db sqldb.DB, userID string) (*User, error) 142 | 143 | // SetPhoto sets user's photo 144 | SetPhoto(db sqldb.DB, userID string, photoURL string) error 145 | } 146 | ``` 147 | 148 | then the `profile` service may require file, and user repository 149 | 150 | ```go 151 | // profile/service.go (continue) 152 | 153 | // NewService creates new profile service 154 | func NewService(db sqldb.DB, users user.Repository, files file.Repository) Service { 155 | return &service{db, users, files} 156 | } 157 | 158 | type service struct { 159 | db sqldb.DB 160 | users user.Repository 161 | files file.Repository 162 | } 163 | ``` 164 | 165 | ### Shared Domain 166 | 167 | Shared Domain is the domain that shared with other domains. 168 | 169 | Shared Domain **SHOULD** **NOT** import other domains to avoid cycle dependency. 170 | 171 | In the example above, `user`, and `file` are shared domains. 172 | 173 | ## Transport 174 | 175 | Transport is the layer between transport to service. 176 | 177 | ### Endpoint 178 | 179 | Endpoint is the endpoint for each transport. 180 | 181 | For Example 182 | 183 | ```go 184 | // profile/service.go 185 | 186 | package profile 187 | 188 | // Service is the profile service 189 | type Service interface { 190 | // Get returns user's profile 191 | Get(userID string) (*Profile, error) 192 | 193 | // Update updates user's profile 194 | Update(profile *Profile) error 195 | 196 | // ChangePhoto changes user profile's photo 197 | ChangePhoto(userID string, r io.Reader) error 198 | } 199 | ``` 200 | 201 | We can create endpoint for profile service like this. 202 | 203 | ```go 204 | // profile/httpendpoint.go 205 | 206 | type getResponse struct { 207 | Profile 208 | } 209 | 210 | // makeGetEndpoint creates http's endpoint for Get 211 | func makeGetEndpoint(s Service) http.Handler { 212 | return http.HandlerFunc(w http.ResponseWriter, r *http.Request) { 213 | userID := auth.GetUserID(r.Context()) 214 | resp, err := s.Get(userID) 215 | if err != nil { 216 | httptransport.HandleError(w, r, err) 217 | return 218 | } 219 | httptransport.HandleSuccess(w, r, &getResponse{*resp}) 220 | }) 221 | } 222 | ``` 223 | 224 | then we can use this endpoint in transport like this. 225 | 226 | ```go 227 | // profile/httptransport.go 228 | 229 | // MakeHandler creates new profile http handler 230 | func MakeHandler(s Service) http.Handler { 231 | m := http.NewServeMux() 232 | 233 | m.Handle("/profile", makeGetEndpoint(s)) 234 | 235 | return httptransport.ChainMiddleware( 236 | httptransport.MustSignedIn(), 237 | )(mux) 238 | } 239 | ``` 240 | 241 | ## Controller 242 | 243 | Controller uses to handle multiple-page application (MPA). 244 | 245 | ```go 246 | // landing/handler.go 247 | 248 | package landing 249 | 250 | import ( 251 | "path/to/profile/internal/filesystem" 252 | // other imports ... 253 | ) 254 | 255 | // New creates new landing handler 256 | func New(ctrl Controller) http.Handler { 257 | m := http.NewServeMux() 258 | m.Handle("/-/", http.StripPrefix("/-", filesystem.New("assets/landing"))) 259 | 260 | r := httprouter.New() 261 | r.Get("/", ctrl.Index) 262 | r.Get("/faq", ctrl.Faq) 263 | 264 | m.Handle("/", r) 265 | 266 | return httptransport.ChainMiddleware( 267 | httptransport.RejectCORS(), 268 | httptransport.RequireSession(), 269 | )(m) 270 | } 271 | ``` 272 | 273 | ```go 274 | // landing/landing.go 275 | 276 | package landing 277 | 278 | import ( 279 | "path/to/project/internal/sqldb" 280 | "path/to/project/landing/service" 281 | "path/to/project/internal/renderer" 282 | ) 283 | 284 | // Controller is the landing controller 285 | type Controller interface { 286 | Index http.HandlerFunc 287 | Faq http.HandlerFunc 288 | } 289 | 290 | // NewController creates new landing controller 291 | func NewController(db sqldb.DB, landings service.Landing) Controller { 292 | c := &ctrl{landings} 293 | // load templates to renderer 294 | return c 295 | } 296 | 297 | type ctrl struct { 298 | renderer renderer.Renderer 299 | landings service.Landing 300 | } 301 | 302 | func (c *ctrl) Index(w http.ResponseWriter, r http.Request) { 303 | c.renderer.View("index", nil) 304 | } 305 | ``` 306 | 307 | ## Testing 308 | 309 | ## Service 310 | 311 | ### Mock Repository 312 | 313 | ```go 314 | // mock/user.go 315 | 316 | // NewUserRepository creates new mock user repository 317 | func NewUserRepository() user.Repository { 318 | // 319 | } 320 | ``` 321 | 322 | ### Testing 323 | 324 | ```go 325 | // profile/service_test.go 326 | 327 | package profile_test 328 | 329 | func TestGetNotFound(t testing.T) { 330 | mockDB := mock.NewDB() 331 | mockUsers := mock.NewUserRepository() 332 | mockFiles = mock.NewFileRepository() 333 | s := profile.NewService(mockDB, mockUsers, mockFiles) 334 | resp, err := s.Get("not exists user id") 335 | if err != profile.ErrNotExists { 336 | t.Errorf("expected Get not found user returns ErrNotExists, got %v", err) 337 | } 338 | if resp != nil { 339 | t.Errorf("expected Get not found user returns nil response; got %v", resp) 340 | } 341 | } 342 | ``` 343 | 344 | ## Endpoint 345 | 346 | ```go 347 | // profile/endpoint_internal_test.go 348 | 349 | package endpoint 350 | 351 | // mock service, use your own mocking style 352 | typs mockService struct { 353 | GetFunc func(userID string) (*Profile, error) 354 | ChangePhotoFunc func(userID string, r io.Reader) error 355 | } 356 | 357 | func (s *mockService) Get(userID string) (*Profile, error) { 358 | return s.GetFunc(userID) 359 | } 360 | 361 | func (s *mockService) ChangePhoto(userID string, r io.Reader) error { 362 | return s.ChangePhotoFunc(userID, r) 363 | } 364 | 365 | func TestGetHandler(t testing.T) { 366 | s := &mockService { 367 | GetFunc: func(userID string) (*Profile, error) { 368 | return nil, ErrNotFound 369 | }, 370 | } 371 | h := makeGetEndpoint(s) 372 | req := httptest.NewRequest(...) 373 | resp := httptest.NewResponseRecoder(...) 374 | h.ServeHTTP(w, r) 375 | 376 | // check response 377 | } 378 | ``` 379 | 380 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-project-guideline 2 | 3 | Acoshift's Go Project Guideline. 4 | 5 | ## Anti-Pattern 6 | 7 | For High Producivity and Maintainable without Test 8 | 9 | [AntiPattern.md](https://github.com/acoshift/go-project-guideline/blob/master/AntiPattern.md) 10 | 11 | ## DDD 12 | 13 | For Low Producivity and Maintainable with Test 14 | 15 | [DDD.md](https://github.com/acoshift/go-project-guideline/blob/master/DDD.md) 16 | 17 | ## Others 18 | 19 | - [Kubernetes Checklist](https://github.com/acoshift/go-project-guideline/blob/master/k8s.md) 20 | - [HTTP Headers Checklist](https://github.com/acoshift/go-project-guideline/blob/master/headers.md) 21 | - [SQL](https://github.com/acoshift/go-project-guideline/blob/master/sql.md) 22 | 23 | -------------------------------------------------------------------------------- /headers.md: -------------------------------------------------------------------------------- 1 | # HTTP Headers Checklist 2 | 3 | - Vary: Accept-Encoding 4 | 5 | Protect cache wrong encoding 6 | 7 | - Cache-Control: no-cache, no-store, must-revalidate 8 | 9 | For rendered HTML 10 | 11 | - Cache-Control: public, max-age=31536000 12 | 13 | For assets 14 | 15 | - X-Content-Type-Options: nosniff 16 | 17 | - X-Frame-Options: deny 18 | 19 | - X-Xss-Protection: 1; mode=block 20 | 21 | - Strict-Transport-Security: max-age=63072000; includeSubDomains; preload 22 | 23 | - Content-Security-Policy 24 | 25 | ``` 26 | default-src 'self'; 27 | ``` 28 | 29 | ``` 30 | default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; 31 | ``` 32 | 33 | - Referrer-Policy: same-origin 34 | 35 | -------------------------------------------------------------------------------- /k8s.md: -------------------------------------------------------------------------------- 1 | # Kubernetes Checklist 2 | 3 | - Label 4 | 5 | ```yaml 6 | metadata: 7 | labels: 8 | app: appname 9 | ``` 10 | 11 | - Pod Resources 12 | 13 | ```yaml 14 | resoucres: 15 | requests: 16 | cpu: 100m 17 | memory: 200Mi 18 | ``` 19 | 20 | - Health Check 21 | 22 | ```yaml 23 | livenessProbe: 24 | httpGet: 25 | path: /healthz 26 | port: 18080 27 | scheme: HTTP 28 | initialDelaySeconds: 10 29 | periodSeconds: 10 30 | successThreshold: 1 31 | failureThreshold: 3 32 | timeoutSeconds: 5 33 | readinessProbe: 34 | httpGet: 35 | path: /healthz 36 | port: 18080 37 | scheme: HTTP 38 | periodSeconds: 1 39 | successThreshold: 1 40 | failureThreshold: 3 41 | timeoutSeconds: 1 42 | ``` 43 | 44 | - Deployment Strategy 45 | 46 | ```yaml 47 | strategy: 48 | type: RollingUpdate 49 | rollingUpdate: 50 | maxSurge: 1 51 | maxUnavailable: 0 52 | ``` 53 | 54 | - Deployment Replicas 55 | 56 | ```yaml 57 | replicas: 2 # set to node - 1 58 | ``` 59 | 60 | - Revision History Limit 61 | 62 | ```yaml 63 | revisionHistoryLimit: 3 64 | ``` 65 | 66 | - Pod Anti-Affinity 67 | 68 | ```yaml 69 | affinity: 70 | podAntiAffinity: 71 | preferredDuringSchedulingIgnoredDuringExecution: 72 | - podAffinityTerm: 73 | labelSelector: 74 | matchExpressions: 75 | - key: app 76 | operator: In 77 | values: 78 | - appname 79 | topologyKey: kubernetes.io/hostname 80 | weight: 100 81 | ``` 82 | 83 | or 84 | 85 | ```yaml 86 | affinity: 87 | podAntiAffinity: 88 | requiredDuringSchedulingIgnoredDuringExecution: 89 | - labelSelector: 90 | matchExpressions: 91 | - key: app 92 | operator: In 93 | values: 94 | - appname 95 | topologyKey: kubernetes.io/hostname 96 | ``` 97 | 98 | - Pre-stop hook (if app not graceful shutdown) 99 | 100 | ```yaml 101 | lifecycle: 102 | preStop: 103 | exec: 104 | command: 105 | - sleep 106 | - "10" 107 | ``` 108 | 109 | - Use External Name service if connect to external database (not in k8s cluster) 110 | 111 | ```yaml 112 | apiVersion: v1 113 | kind: Service 114 | metadata: 115 | name: postgres 116 | spec: 117 | type: ExternalName 118 | externalName: postgres.cluster.yourdomain.com 119 | ``` 120 | 121 | - Add minimum pod for kube-dns-autoscaler config 122 | 123 | `$ kubectl edit cm/kube-dns-autoscaler -n kube-system` 124 | 125 | set min to 2 126 | 127 | ```yaml 128 | data: 129 | linear: '{"coresPerReplica":256,"min":2,"nodesPerReplica":16}' 130 | ``` 131 | 132 | or using preventSinglePointFailure 133 | 134 | ```yaml 135 | data: 136 | linear: '{"coresPerReplica":256,"nodesPerReplica":16,"preventSinglePointFailure":true}' 137 | ``` -------------------------------------------------------------------------------- /sql.md: -------------------------------------------------------------------------------- 1 | # SQL Guideline 2 | 3 | 1. Always use lower case. 4 | 1. Indent with tab 5 | 1. Always use not null, until you need null value 6 | 1. Always set default to datatype default value (ex. int = 0, varchar = '', bool = false), except timestamp can set to now() 7 | 8 | ## Example 9 | 10 | ### Create Table 11 | 12 | ```sql 13 | create table users ( 14 | id uuid default gen_random_uuid(), 15 | email varchar not null, 16 | password varchar not null, 17 | is_admin bool not null default false, 18 | created_at timestamp not null default now(), 19 | updated_at timestamp not null default now(), 20 | primary key (id) 21 | ); 22 | create unique index on users (email); 23 | create index on users (created_at desc); 24 | ``` 25 | 26 | ### Insert 27 | 28 | ```sql 29 | insert into users 30 | (email, password) 31 | values 32 | ($1, $2); 33 | ``` 34 | 35 | ```sql 36 | insert into users 37 | ( 38 | email, password, name, is_admin, is_ban, 39 | created_at 40 | ) 41 | values 42 | ( 43 | $1, $2 $3, $4, $5, $6 44 | ) 45 | returning id; 46 | ``` 47 | 48 | ### Update 49 | 50 | ```sql 51 | update users 52 | set 53 | is_admin = $2, 54 | updated_at = now() 55 | where id = $1; 56 | ``` 57 | 58 | ### Upsert 59 | 60 | ```sql 61 | insert into users 62 | (email, password) 63 | values 64 | ($1, $2) 65 | on conflict (email) do update 66 | set 67 | password = excluded.password 68 | returning id; 69 | ``` 70 | 71 | ### Select 72 | 73 | ```sql 74 | select 75 | email, password, is_admin, 76 | created_at, updated_at 77 | from users 78 | where email = $1; 79 | ``` 80 | 81 | ```sql 82 | select 83 | email, password, is_admin, 84 | created_at, updated_at 85 | from users 86 | where 87 | is_admin = true 88 | and is_ban = false 89 | order by created_at desc 90 | limit 100 91 | offset 200; 92 | ``` 93 | 94 | ```sql 95 | select 96 | u.username, t.value, t.created_at 97 | from t1 as t 98 | left join users as u on u.id = t.user_id 99 | order by created_at desc; 100 | ``` 101 | 102 | ```sql 103 | select 104 | u.username, t.value, t.created_at 105 | from t1 as t 106 | left join users as u 107 | on 108 | u.id = t.user_id 109 | and t.created_at > now() - '7d' 110 | order by created_at desc; 111 | ``` 112 | 113 | ```sql 114 | select 115 | date at time zone 'UTC+7' at time zone 'UTC', 116 | sum(case when type = 't1' and c = 'A' then -a else 0 end) as a, 117 | sum(case when type = 't2' and c = 'B' then a else 0 end) as b, 118 | sum(case when type = 't2' and c = 'C' then a else 0 end) as c 119 | from 120 | ( 121 | select 122 | date_trunc($1, created_at at time zone 'UTC+7') as date, 123 | c, type, 124 | sum(a) as a 125 | from t7 126 | where 127 | (type = 't1' and c = 'A') 128 | or (type = 't2' and c = 'B') 129 | or (type = 't2' and c = 'C') 130 | group by 131 | date_trunc('day', created_at at time zone 'UTC+7'), 132 | c, 133 | type 134 | ) as t 135 | group by date 136 | order by date desc 137 | offset 60 138 | limit 30; 139 | ``` 140 | 141 | ### Delete 142 | 143 | ```sql 144 | delete from users 145 | where is_ban = true; 146 | ``` 147 | -------------------------------------------------------------------------------- /vscode.md: -------------------------------------------------------------------------------- 1 | # VSCode 2 | 3 | ## Plugin 4 | 5 | - Go 6 | - ESLint 7 | - markdownlint 8 | - Sass 9 | - Vetur 10 | - Better TOML 11 | - Beautify 12 | 13 | ## Config 14 | 15 | ```json 16 | { 17 | "editor.tabCompletion": true, 18 | "editor.snippetSuggestions": "inline", 19 | "editor.insertSpaces": false, 20 | "editor.tabSize": 4, 21 | "editor.fontSize": 14, 22 | "editor.renderWhitespace": "none", 23 | "editor.rulers": [140], 24 | "editor.renderIndentGuides": false, 25 | "files.trimTrailingWhitespace": true, 26 | "explorer.confirmDragAndDrop": false, 27 | "go.liveErrors": { 28 | "enabled": true, 29 | "delay": 500 30 | }, 31 | "files.exclude": { 32 | "**/.git": true, 33 | "**/.svn": true, 34 | "**/.hg": true, 35 | "**/CVS": true, 36 | "**/.DS_Store": true, 37 | "**/vendor/**": true, 38 | "**/node_modules/**": true 39 | }, 40 | "[go]": { 41 | "editor.formatOnSave": true 42 | }, 43 | "files.associations": { 44 | "*.tmpl": "html" 45 | }, 46 | "extensions.ignoreRecommendations": false, 47 | "emmet.triggerExpansionOnTab": true, 48 | "eslint.packageManager": "yarn", 49 | "go.addTags": { 50 | "tags": "json", 51 | "options": "json=", 52 | "promptForTags": false, 53 | "transform": "camelcase" 54 | }, 55 | "html.format.endWithNewline": true, 56 | "html.format.wrapLineLength": 140, 57 | "html.suggest.angular1": false, 58 | "html.suggest.ionic": false, 59 | "html.validate.scripts": true, 60 | "html.validate.styles": true 61 | } 62 | ``` --------------------------------------------------------------------------------