797 lines
17 KiB
Text
797 lines
17 KiB
Text
---
|
|
title: Services
|
|
description: Build modular, reusable application components with services
|
|
sidebar:
|
|
order: 2
|
|
---
|
|
|
|
## Service Architecture
|
|
|
|
Wails **services** provide a structured way to organise application logic with modular, self-contained components. Services are lifecycle-aware with startup and shutdown hooks, automatically bound to the frontend, dependency-injectable, and fully testable in isolation.
|
|
|
|
## Quick Start
|
|
|
|
```go
|
|
type GreetService struct {
|
|
prefix string
|
|
}
|
|
|
|
func NewGreetService(prefix string) *GreetService {
|
|
return &GreetService{prefix: prefix}
|
|
}
|
|
|
|
func (g *GreetService) Greet(name string) string {
|
|
return g.prefix + name + "!"
|
|
}
|
|
|
|
// Register
|
|
app := application.New(application.Options{
|
|
Services: []application.Service{
|
|
application.NewService(NewGreetService("Hello, ")),
|
|
},
|
|
})
|
|
```
|
|
|
|
**That's it!** `Greet` is now callable from JavaScript:
|
|
|
|
```js
|
|
import { Greet } from './bindings/main/GreetService';
|
|
|
|
const message = await Greet("World");
|
|
console.log(message); // "Hello, World!"
|
|
```
|
|
|
|
## Creating Services
|
|
|
|
### Basic Service
|
|
|
|
```go
|
|
type CalculatorService struct{}
|
|
|
|
func (c *CalculatorService) Add(a, b int) int {
|
|
return a + b
|
|
}
|
|
|
|
func (c *CalculatorService) Subtract(a, b int) int {
|
|
return a - b
|
|
}
|
|
```
|
|
|
|
**Register:**
|
|
|
|
```go
|
|
app := application.New(application.Options{
|
|
Services: []application.Service{
|
|
application.NewService(&CalculatorService{}),
|
|
},
|
|
})
|
|
```
|
|
|
|
**Key points:**
|
|
- Only **exported methods** (PascalCase) are bound
|
|
- Services are **singletons** (one instance)
|
|
- Methods can return `(value, error)`
|
|
|
|
### Service with State
|
|
|
|
```go
|
|
type CounterService struct {
|
|
count int
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func (c *CounterService) Increment() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.count++
|
|
return c.count
|
|
}
|
|
|
|
func (c *CounterService) GetCount() int {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.count
|
|
}
|
|
|
|
func (c *CounterService) Reset() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.count = 0
|
|
}
|
|
```
|
|
|
|
**Important:** Services are shared across all windows. Always use mutexes for thread safety.
|
|
|
|
### Service with Dependencies
|
|
|
|
```go
|
|
type UserService struct {
|
|
db *sql.DB
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewUserService(db *sql.DB, logger *slog.Logger) *UserService {
|
|
return &UserService{
|
|
db: db,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func (u *UserService) GetUser(id int) (*User, error) {
|
|
u.logger.Info("Getting user", "id", id)
|
|
|
|
var user User
|
|
err := u.db.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
```
|
|
|
|
**Register with dependencies:**
|
|
|
|
```go
|
|
db, _ := sql.Open("sqlite3", "app.db")
|
|
logger := slog.Default()
|
|
|
|
app := application.New(application.Options{
|
|
Services: []application.Service{
|
|
application.NewService(NewUserService(db, logger)),
|
|
},
|
|
})
|
|
```
|
|
|
|
## Service Lifecycle
|
|
|
|
### ServiceStartup
|
|
|
|
Called when application starts:
|
|
|
|
```go
|
|
func (u *UserService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
|
u.logger.Info("UserService starting up")
|
|
|
|
// Initialise resources
|
|
if err := u.db.Ping(); err != nil {
|
|
return fmt.Errorf("database not available: %w", err)
|
|
}
|
|
|
|
// Run migrations
|
|
if err := u.runMigrations(); err != nil {
|
|
return fmt.Errorf("migrations failed: %w", err)
|
|
}
|
|
|
|
// Start background tasks
|
|
go u.backgroundSync(ctx)
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
**Use cases:**
|
|
- Initialise resources
|
|
- Validate configuration
|
|
- Run migrations
|
|
- Start background tasks
|
|
- Connect to external services
|
|
|
|
**Important:**
|
|
- Services start in **registration order**
|
|
- Return error to **prevent app startup**
|
|
- Use `ctx` for cancellation
|
|
|
|
### ServiceShutdown
|
|
|
|
Called when application shuts down:
|
|
|
|
```go
|
|
func (u *UserService) ServiceShutdown() error {
|
|
u.logger.Info("UserService shutting down")
|
|
|
|
// Close database
|
|
if err := u.db.Close(); err != nil {
|
|
return fmt.Errorf("failed to close database: %w", err)
|
|
}
|
|
|
|
// Cleanup resources
|
|
u.cleanup()
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
**Use cases:**
|
|
- Close connections
|
|
- Save state
|
|
- Cleanup resources
|
|
- Flush buffers
|
|
- Cancel background tasks
|
|
|
|
**Important:**
|
|
- Services shutdown in **reverse order**
|
|
- Application context already cancelled
|
|
- Return error to **log warning** (doesn't prevent shutdown)
|
|
|
|
### Complete Lifecycle Example
|
|
|
|
```go
|
|
type DatabaseService struct {
|
|
db *sql.DB
|
|
logger *slog.Logger
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func NewDatabaseService(logger *slog.Logger) *DatabaseService {
|
|
return &DatabaseService{logger: logger}
|
|
}
|
|
|
|
func (d *DatabaseService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
|
d.logger.Info("Starting database service")
|
|
|
|
// Open database
|
|
db, err := sql.Open("sqlite3", "app.db")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
d.db = db
|
|
|
|
// Test connection
|
|
if err := db.Ping(); err != nil {
|
|
return fmt.Errorf("database not available: %w", err)
|
|
}
|
|
|
|
// Start background cleanup
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
d.cancel = cancel
|
|
go d.periodicCleanup(ctx)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *DatabaseService) ServiceShutdown() error {
|
|
d.logger.Info("Shutting down database service")
|
|
|
|
// Cancel background tasks
|
|
if d.cancel != nil {
|
|
d.cancel()
|
|
}
|
|
|
|
// Close database
|
|
if d.db != nil {
|
|
if err := d.db.Close(); err != nil {
|
|
return fmt.Errorf("failed to close database: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *DatabaseService) periodicCleanup(ctx context.Context) {
|
|
ticker := time.NewTicker(1 * time.Hour)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
d.cleanup()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Service Options
|
|
|
|
### Custom Name
|
|
|
|
```go
|
|
app := application.New(application.Options{
|
|
Services: []application.Service{
|
|
application.NewServiceWithOptions(&MyService{}, application.ServiceOptions{
|
|
Name: "CustomServiceName",
|
|
}),
|
|
},
|
|
})
|
|
```
|
|
|
|
**Use cases:**
|
|
- Multiple instances of same service type
|
|
- Clearer logging
|
|
- Better debugging
|
|
|
|
### HTTP Routes
|
|
|
|
Services can handle HTTP requests:
|
|
|
|
```go
|
|
type FileService struct {
|
|
root string
|
|
}
|
|
|
|
func (f *FileService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// Serve files from root directory
|
|
http.FileServer(http.Dir(f.root)).ServeHTTP(w, r)
|
|
}
|
|
|
|
// Register with route
|
|
app := application.New(application.Options{
|
|
Services: []application.Service{
|
|
application.NewServiceWithOptions(&FileService{root: "./files"}, application.ServiceOptions{
|
|
Route: "/files",
|
|
}),
|
|
},
|
|
})
|
|
```
|
|
|
|
**Access:** `http://wails.localhost/files/...`
|
|
|
|
**Use cases:**
|
|
- File serving
|
|
- Custom APIs
|
|
- WebSocket endpoints
|
|
- Media streaming
|
|
|
|
## Service Patterns
|
|
|
|
### Repository Pattern
|
|
|
|
```go
|
|
type UserRepository struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func (r *UserRepository) GetByID(id int) (*User, error) {
|
|
// Database query
|
|
}
|
|
|
|
func (r *UserRepository) Create(user *User) error {
|
|
// Insert user
|
|
}
|
|
|
|
func (r *UserRepository) Update(user *User) error {
|
|
// Update user
|
|
}
|
|
|
|
func (r *UserRepository) Delete(id int) error {
|
|
// Delete user
|
|
}
|
|
```
|
|
|
|
### Service Layer Pattern
|
|
|
|
```go
|
|
type UserService struct {
|
|
repo *UserRepository
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func (s *UserService) RegisterUser(email, password string) (*User, error) {
|
|
// Validate
|
|
if !isValidEmail(email) {
|
|
return nil, errors.New("invalid email")
|
|
}
|
|
|
|
// Hash password
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create user
|
|
user := &User{
|
|
Email: email,
|
|
PasswordHash: string(hash),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.repo.Create(user); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.logger.Info("User registered", "email", email)
|
|
return user, nil
|
|
}
|
|
```
|
|
|
|
### Factory Pattern
|
|
|
|
```go
|
|
type ServiceFactory struct {
|
|
db *sql.DB
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func (f *ServiceFactory) CreateUserService() *UserService {
|
|
return &UserService{
|
|
repo: &UserRepository{db: f.db},
|
|
logger: f.logger,
|
|
}
|
|
}
|
|
|
|
func (f *ServiceFactory) CreateOrderService() *OrderService {
|
|
return &OrderService{
|
|
repo: &OrderRepository{db: f.db},
|
|
logger: f.logger,
|
|
}
|
|
}
|
|
```
|
|
|
|
### Event-Driven Pattern
|
|
|
|
```go
|
|
type OrderService struct {
|
|
app *application.Application
|
|
}
|
|
|
|
func (o *OrderService) CreateOrder(items []Item) (*Order, error) {
|
|
order := &Order{
|
|
Items: items,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
// Save order
|
|
if err := o.saveOrder(order); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Emit event
|
|
o.app.Event.Emit("order-created", order)
|
|
|
|
return order, nil
|
|
}
|
|
```
|
|
|
|
## Dependency Injection
|
|
|
|
### Constructor Injection
|
|
|
|
```go
|
|
type EmailService struct {
|
|
smtp *smtp.Client
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewEmailService(smtp *smtp.Client, logger *slog.Logger) *EmailService {
|
|
return &EmailService{
|
|
smtp: smtp,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Register
|
|
smtpClient := createSMTPClient()
|
|
logger := slog.Default()
|
|
|
|
app := application.New(application.Options{
|
|
Services: []application.Service{
|
|
application.NewService(NewEmailService(smtpClient, logger)),
|
|
},
|
|
})
|
|
```
|
|
|
|
### Application Injection
|
|
|
|
```go
|
|
type NotificationService struct {
|
|
app *application.Application
|
|
}
|
|
|
|
func NewNotificationService(app *application.Application) *NotificationService {
|
|
return &NotificationService{app: app}
|
|
}
|
|
|
|
func (n *NotificationService) Notify(message string) {
|
|
// Use application to emit events
|
|
n.app.Event.Emit("notification", message)
|
|
|
|
// Or show system notification
|
|
n.app.ShowNotification(message)
|
|
}
|
|
|
|
// Register after app creation
|
|
app := application.New(application.Options{})
|
|
app.RegisterService(application.NewService(NewNotificationService(app)))
|
|
```
|
|
|
|
### Service-to-Service Dependencies
|
|
|
|
```go
|
|
type OrderService struct {
|
|
userService *UserService
|
|
emailService *EmailService
|
|
}
|
|
|
|
func NewOrderService(userService *UserService, emailService *EmailService) *OrderService {
|
|
return &OrderService{
|
|
userService: userService,
|
|
emailService: emailService,
|
|
}
|
|
}
|
|
|
|
// Register in order
|
|
userService := &UserService{}
|
|
emailService := &EmailService{}
|
|
orderService := NewOrderService(userService, emailService)
|
|
|
|
app := application.New(application.Options{
|
|
Services: []application.Service{
|
|
application.NewService(userService),
|
|
application.NewService(emailService),
|
|
application.NewService(orderService),
|
|
},
|
|
})
|
|
```
|
|
|
|
## Testing Services
|
|
|
|
### Unit Testing
|
|
|
|
```go
|
|
func TestCalculatorService_Add(t *testing.T) {
|
|
calc := &CalculatorService{}
|
|
|
|
result := calc.Add(2, 3)
|
|
|
|
if result != 5 {
|
|
t.Errorf("expected 5, got %d", result)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Testing with Dependencies
|
|
|
|
```go
|
|
func TestUserService_GetUser(t *testing.T) {
|
|
// Create mock database
|
|
db, mock, _ := sqlmock.New()
|
|
defer db.Close()
|
|
|
|
// Set expectations
|
|
rows := sqlmock.NewRows([]string{"id", "name"}).
|
|
AddRow(1, "Alice")
|
|
mock.ExpectQuery("SELECT").WillReturnRows(rows)
|
|
|
|
// Create service
|
|
service := NewUserService(db, slog.Default())
|
|
|
|
// Test
|
|
user, err := service.GetUser(1)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if user.Name != "Alice" {
|
|
t.Errorf("expected Alice, got %s", user.Name)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Testing Lifecycle
|
|
|
|
```go
|
|
func TestDatabaseService_Lifecycle(t *testing.T) {
|
|
service := NewDatabaseService(slog.Default())
|
|
|
|
// Test startup
|
|
ctx := context.Background()
|
|
err := service.ServiceStartup(ctx, application.ServiceOptions{})
|
|
if err != nil {
|
|
t.Fatalf("startup failed: %v", err)
|
|
}
|
|
|
|
// Test functionality
|
|
// ...
|
|
|
|
// Test shutdown
|
|
err = service.ServiceShutdown()
|
|
if err != nil {
|
|
t.Fatalf("shutdown failed: %v", err)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### ✅ Do
|
|
|
|
- **Single responsibility** - One service, one purpose
|
|
- **Constructor injection** - Pass dependencies explicitly
|
|
- **Thread-safe state** - Use mutexes
|
|
- **Return errors** - Don't panic
|
|
- **Log important events** - Use structured logging
|
|
- **Test in isolation** - Mock dependencies
|
|
|
|
### ❌ Don't
|
|
|
|
- **Don't use global state** - Pass dependencies
|
|
- **Don't block startup** - Keep ServiceStartup fast
|
|
- **Don't ignore shutdown** - Always cleanup
|
|
- **Don't create circular dependencies** - Design carefully
|
|
- **Don't expose internal methods** - Keep them private
|
|
- **Don't forget thread safety** - Services are shared
|
|
|
|
## Complete Example
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
type User struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type UserService struct {
|
|
db *sql.DB
|
|
logger *slog.Logger
|
|
cache map[int]*User
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func NewUserService(logger *slog.Logger) *UserService {
|
|
return &UserService{
|
|
logger: logger,
|
|
cache: make(map[int]*User),
|
|
}
|
|
}
|
|
|
|
func (u *UserService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
|
u.logger.Info("Starting UserService")
|
|
|
|
// Open database
|
|
db, err := sql.Open("sqlite3", "users.db")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
u.db = db
|
|
|
|
// Create table
|
|
_, err = db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
email TEXT UNIQUE NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create table: %w", err)
|
|
}
|
|
|
|
// Preload cache
|
|
if err := u.loadCache(); err != nil {
|
|
return fmt.Errorf("failed to load cache: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (u *UserService) ServiceShutdown() error {
|
|
u.logger.Info("Shutting down UserService")
|
|
|
|
if u.db != nil {
|
|
return u.db.Close()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (u *UserService) GetUser(id int) (*User, error) {
|
|
// Check cache first
|
|
u.mu.RLock()
|
|
if user, ok := u.cache[id]; ok {
|
|
u.mu.RUnlock()
|
|
return user, nil
|
|
}
|
|
u.mu.RUnlock()
|
|
|
|
// Query database
|
|
var user User
|
|
err := u.db.QueryRow(
|
|
"SELECT id, name, email, created_at FROM users WHERE id = ?",
|
|
id,
|
|
).Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("user %d not found", id)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("database error: %w", err)
|
|
}
|
|
|
|
// Update cache
|
|
u.mu.Lock()
|
|
u.cache[id] = &user
|
|
u.mu.Unlock()
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
func (u *UserService) CreateUser(name, email string) (*User, error) {
|
|
result, err := u.db.Exec(
|
|
"INSERT INTO users (name, email) VALUES (?, ?)",
|
|
name, email,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
id, _ := result.LastInsertId()
|
|
|
|
user := &User{
|
|
ID: int(id),
|
|
Name: name,
|
|
Email: email,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
// Update cache
|
|
u.mu.Lock()
|
|
u.cache[int(id)] = user
|
|
u.mu.Unlock()
|
|
|
|
u.logger.Info("User created", "id", id, "email", email)
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (u *UserService) loadCache() error {
|
|
rows, err := u.db.Query("SELECT id, name, email, created_at FROM users")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
|
|
for rows.Next() {
|
|
var user User
|
|
if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt); err != nil {
|
|
return err
|
|
}
|
|
u.cache[user.ID] = &user
|
|
}
|
|
|
|
return rows.Err()
|
|
}
|
|
|
|
func main() {
|
|
app := application.New(application.Options{
|
|
Name: "User Management",
|
|
Services: []application.Service{
|
|
application.NewService(NewUserService(slog.Default())),
|
|
},
|
|
})
|
|
|
|
app.Window.New()
|
|
app.Run()
|
|
}
|
|
```
|
|
|
|
## Next Steps
|
|
|
|
- [Method Bindings](/features/bindings/methods) - Learn how to bind Go methods to JavaScript
|
|
- [Models](/features/bindings/models) - Bind complex data structures
|
|
- [Events](/features/events/system) - Use events for pub/sub communication
|
|
- [Best Practices](/features/bindings/best-practices) - Service design patterns and best practices
|
|
|
|
---
|
|
|
|
**Questions?** Ask in [Discord](https://discord.gg/JDdSxwjhGf) or check the [service examples](https://github.com/wailsapp/wails/tree/v3-alpha/v3/examples).
|